diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0521878c..7d586219 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,3 +255,61 @@ jobs: - name: Test Go module run: go test ./... + dotnet: + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/dotnet + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + # The .NET 10 SDK builds the net8.0 target and understands the .slnx + # solution format; the 8.0.x entry provides the net8.0 runtime so the + # net8.0 tests run natively. + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Install Node deps + working-directory: . + run: npm ci + + # Verify the committed C# sources are in sync with the TypeScript + # protocol definitions. `git status --porcelain` (like the Kotlin / Go + # jobs) so a newly-emitted file also fails the check. + - name: Verify generated .NET is up to date + working-directory: . + run: | + npm run generate:dotnet + if [ -n "$(git status --porcelain -- clients/dotnet)" ]; then + echo "::error::Generated .NET sources are out of date. Run 'npm run generate:dotnet' and commit the result." + git status --porcelain -- clients/dotnet + git --no-pager diff -- clients/dotnet + exit 1 + fi + + - name: Restore .NET solution + run: dotnet restore + + - name: Build .NET solution + run: dotnet build --no-restore --configuration Release + + - name: Test .NET solution + run: dotnet test --no-build --configuration Release + + # Blocking test-parity gate: fails while any test in the parity manifest + # (clients/dotnet/tests/parity-manifest.txt) is missing, enumerating exactly + # which methods to add. Green only at full Swift/TS parity. See the parity + # contract in clients/dotnet/AGENTS.md ("Test-parity gate"). Runs last so the + # build/test signal above is still reported while parity is in progress. + - name: Enforce .NET test parity (blocking; enumerates missing tests) + run: bash clients/dotnet/scripts/check-test-parity.sh + diff --git a/AGENTS.md b/AGENTS.md index 89011093..eadc7d8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,16 @@ Cross-cutting rules for AI coding agents working in this repository. Per-client codegen conventions are in `clients/kotlin/AGENTS.md`, -`clients/swift/AGENTS.md`, and `clients/go/AGENTS.md`. Editorial rules +`clients/swift/AGENTS.md`, `clients/go/AGENTS.md`, and +`clients/dotnet/AGENTS.md`. Editorial rules for protocol types are in `.github/instructions/general-instructions.instructions.md`. Release mechanics are in [`RELEASING.md`](RELEASING.md). ## Updating CHANGELOGs -This repo ships six independently-versioned artifacts (the spec plus -the Rust / Kotlin / Swift / TypeScript / Go clients), each with its +This repo ships seven independently-versioned artifacts (the spec plus +the Rust / Kotlin / Swift / TypeScript / Go / .NET clients), each with its own `CHANGELOG.md` in Keep-a-Changelog format. The publish workflows refuse to release a tag whose matching `## [X.Y.Z]` heading is missing, so every user-visible change should land its CHANGELOG bullet @@ -50,6 +51,7 @@ Map source paths to changelogs: | `clients/swift/**` (non-generated) | `clients/swift/CHANGELOG.md` only. | | `clients/typescript/**` (non-generated) | `clients/typescript/CHANGELOG.md` only. | | `clients/go/**` (non-generated) | `clients/go/CHANGELOG.md` only. | +| `clients/dotnet/**` (non-generated) | `clients/dotnet/CHANGELOG.md` only. | | `schema/**` | Root `CHANGELOG.md` (the schema is a spec output). | | `scripts/generate*.ts` that changes any client's generated output | Every affected client's `CHANGELOG.md`. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9737f30c..3e923c1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,8 @@ against them. | `clients/kotlin/` | Kotlin/JVM library (`com.microsoft.agenthostprotocol:agent-host-protocol`). | | `clients/swift/` | Swift package (consumed by SwiftPM at the repo root). | | `clients/typescript/` | npm package `@microsoft/agent-host-protocol`. | +| `clients/go/` | Go module (`ahptypes`, `ahp`, `ahpws`). | +| `clients/dotnet/` | .NET / NuGet packages (`Microsoft.AgentHostProtocol`, `.Abstractions`, `.WebSockets`). | | `.github/workflows/` | CI and per-artifact publish pipelines. | ## Local dev loop @@ -42,6 +44,8 @@ cd clients/typescript && npm ci && npm test && npm run build cd clients/rust && cargo test --workspace cd clients/kotlin && ./gradlew build swift build && swift test # Swift uses the root Package.swift +cd clients/go && go test ./... +cd clients/dotnet && dotnet test ``` ## Releases diff --git a/README.md b/README.md index 42ae3867..3c54ff1a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The Agent Host Protocol (AHP) defines how a portable, standalone sessions server - **Kotlin** — Add `com.microsoft.agenthostprotocol:agent-host-protocol` from Maven Central to use from Android or any JVM project. See [`clients/kotlin/`](clients/kotlin/) for the source and [`CHANGELOG`](clients/kotlin/CHANGELOG.md). Released via `kotlin/vX.Y.Z` tags. - **TypeScript** — Install `@microsoft/agent-host-protocol` to use the wire types, reducers, `AhpClient`, and the `WebSocketTransport`. See [`clients/typescript/`](clients/typescript/) and [`CHANGELOG`](clients/typescript/CHANGELOG.md). Released via `typescript/vX.Y.Z` tags; the Azure DevOps publish pipeline at [`clients/typescript/pipeline.yml`](clients/typescript/pipeline.yml) picks up the tag, validates it, and publishes to npm. - **Go** — `go get github.com/microsoft/agent-host-protocol/clients/go` to use the `ahptypes` wire types, the `ahp` async client (client + pure reducers + pluggable `Transport`), and the `ahpws` WebSocket transport. See [`clients/go/`](clients/go/) and [`CHANGELOG`](clients/go/CHANGELOG.md). Released via `clients/go/vX.Y.Z` tags — the Go module proxy indexes the directory-prefixed tag directly from this repo, so there is no separate package registry. +- **.NET** — Install `Microsoft.AgentHostProtocol` (and `Microsoft.AgentHostProtocol.WebSockets` for a `ClientWebSocket` transport) to use the wire types, the pure reducers, the async `AhpClient`, and the `MultiHostClient`. The `Microsoft.AgentHostProtocol.Abstractions` package carries the wire types + transport/serializer interfaces alone. See [`clients/dotnet/`](clients/dotnet/) and [`CHANGELOG`](clients/dotnet/CHANGELOG.md). Released to NuGet.org via `dotnet/vX.Y.Z` tags. - **[AHPX](https://github.com/TylerLeonhardt/ahpx)** — A command-line and Node.js client for connecting to AHP servers, managing sessions, and sending prompts. - **[VS Code](https://github.com/microsoft/vscode)** — VS Code includes Agent Sessions client code for working with AHP hosts. @@ -24,7 +25,7 @@ The Agent Host Protocol (AHP) defines how a portable, standalone sessions server - **[VS Code agent host](https://github.com/microsoft/vscode)** — The reference AHP server implementation. Start in [`src/vs/platform/agentHost/node/`](https://github.com/microsoft/vscode/tree/main/src/vs/platform/agentHost/node) when browsing the repository. -For consumers that need to talk to two or more hosts at once, the Rust SDK ships a `MultiHostClient` abstraction in [`ahp::hosts`](https://docs.rs/ahp/latest/ahp/hosts/), the Swift SDK ships `MultiHostClient` in `AgentHostProtocolClient`, and the Go SDK ships `MultiHostClient` in [`ahp/hosts`](clients/go/ahp/hosts/). Single-host consumers use the same API via `MultiHostClient::single` in Rust, `MultiHostClient.single(...)` in Swift, or `hosts.Single(...)` in Go. See [Connecting to Multiple Hosts](https://microsoft.github.io/agent-host-protocol/guide/clients-multi-host) for the design and surface. +For consumers that need to talk to two or more hosts at once, the Rust SDK ships a `MultiHostClient` abstraction in [`ahp::hosts`](https://docs.rs/ahp/latest/ahp/hosts/), the Swift SDK ships `MultiHostClient` in `AgentHostProtocolClient`, the Go SDK ships `MultiHostClient` in [`ahp/hosts`](clients/go/ahp/hosts/), and the .NET SDK ships `MultiHostClient` in `Microsoft.AgentHostProtocol.Hosts`. Single-host consumers use the same API via `MultiHostClient::single` in Rust, `MultiHostClient.single(...)` in Swift, `hosts.Single(...)` in Go, or `MultiHostClient.SingleAsync(...)` in .NET. See [Connecting to Multiple Hosts](https://microsoft.github.io/agent-host-protocol/guide/clients-multi-host) for the design and surface. ## Versioning and releases diff --git a/RELEASING.md b/RELEASING.md index 4c926660..644d3978 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -23,6 +23,7 @@ and a checked-in `clients//release-metadata.json`. | TypeScript | `typescript/vX.Y.Z` | `clients/typescript/pipeline.yml` (Azure DevOps) | npm (`@microsoft/agent-host-protocol`) via ESRP. | | Swift | `vX.Y.Z` (bare) | `.github/workflows/publish-swift.yml` | SwiftPM resolves the tag directly. | | Go | `clients/go/vX.Y.Z` | `.github/workflows/publish-go.yml` | Go module proxy resolves the tag directly. | +| .NET | `dotnet/vX.Y.Z` | maintainer-owned pipeline (see below) | NuGet.org (`Microsoft.AgentHostProtocol`, `.Abstractions`, `.WebSockets`). | > **Why Swift gets the bare semver tag namespace:** SwiftPM only resolves > packages by matching plain `X.Y.Z` / `vX.Y.Z` git tags at the manifest's @@ -146,6 +147,26 @@ trigger started the run. `go get github.com/microsoft/agent-host-protocol/clients/go@vX.Y.Z`; no registry push happens. +### .NET (`dotnet/vX.Y.Z`) + +1. Update `clients/dotnet/VERSION` to the new bare semver string (no + leading `v`). +2. Run `npm run generate:metadata` and commit the regenerated + `clients/dotnet/release-metadata.json`. +3. Rotate `clients/dotnet/CHANGELOG.md`. +4. Merge to `main`. +5. Tag: `git tag dotnet/v0.X.Y && git push origin dotnet/v0.X.Y`. +6. Publish the libraries (`Microsoft.AgentHostProtocol`, + `Microsoft.AgentHostProtocol.Abstractions`, + `Microsoft.AgentHostProtocol.WebSockets`) to NuGet.org. This client does + not ship its own publish automation — the maintainers wire the + `dotnet pack` + `dotnet nuget push` step into their own release pipeline, + the same way the Kotlin and TypeScript packages publish through the signed + Azure DevOps / ESRP pipelines rather than a GitHub Actions registry push. + The per-PR CI job already builds, tests, and runs the test-parity gate for + the solution; `npm run verify:changelog` guards the + `clients/dotnet/VERSION` ↔ `CHANGELOG.md` heading match. + ### Spec (`spec/vX.Y.Z`) 1. Bump `PROTOCOL_VERSION` in `types/version/registry.ts` (and, if the diff --git a/clients/dotnet/.gitignore b/clients/dotnet/.gitignore new file mode 100644 index 00000000..cd42ee34 --- /dev/null +++ b/clients/dotnet/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/clients/dotnet/AGENTS.md b/clients/dotnet/AGENTS.md new file mode 100644 index 00000000..5dec2def --- /dev/null +++ b/clients/dotnet/AGENTS.md @@ -0,0 +1,155 @@ +# Agent Guide — .NET client + +Conventions for AI coding agents working on the .NET client. Cross-cutting +repo rules are in the root [`AGENTS.md`](../../AGENTS.md); release mechanics +are in [`RELEASING.md`](../../RELEASING.md). + +## Layout + +| Path | Contents | +| --- | --- | +| `src/AgentHostProtocol.Abstractions/Generated/*.generated.cs` | **Generated** wire types. Do not edit. | +| `src/AgentHostProtocol.Abstractions/Json/`, `Transport/` | Hand-written serialization support (`AhpUnion`, `UnionConverter`, `WireEnumConverter`, `StringOrMarkdown`) and the `ITransport` / `IAhpSerializer` seams. | +| `src/AgentHostProtocol/` | `AhpClient`, the reducers, the default `SystemTextJsonAhpSerializer`, subscriptions, and the `Hosts/` multi-host runtime. | +| `src/AgentHostProtocol.WebSockets/` | `ClientWebSocket`-based transport. | +| `tests/AgentHostProtocol.Tests/` | xUnit tests, including the shared reducer-fixture conformance suite. | +| `examples/` | Runnable console samples. | + +## Code generation + +Generated files are produced by `scripts/generate-csharp.ts` (run from the repo +root via `npm run generate:dotnet`) from the TypeScript definitions in +`types/`. The generator is modeled on `scripts/generate-go.ts` and shares its +curated struct / enum / union lists — they are protocol-driven, not +language-specific. After changing anything under `types/`, regenerate and +commit; CI fails on any diff between the committed sources and a fresh run. + +## Type mapping (TS → C#) + +- `number` → `long` (or `double` when the property carries `@format float`). +- `unknown` / `object` → `System.Text.Json.JsonElement`; + `Record` → `Dictionary`. +- Optional (`?` / `| undefined` / `| null`) fields → nullable + `[JsonIgnore( + Condition = JsonIgnoreCondition.WhenWritingNull)]`. Required fields serialize + their value (a required reference left null serializes as `null`, mirroring + Go's `nil`-slice semantics). +- String enums → C# `enum` with `[WireValue("…")]` per member, (de)serialized + by `WireEnumConverter`. Bitset enums → `[Flags] enum : uint`, serialized + as their numeric value so unknown future bits round-trip. +- Discriminated unions → a sealed wrapper deriving from `AhpUnion` (carrying + `object? Value`) plus a generated `UnionConverter`. Unknown discriminator + values are preserved verbatim as a raw `JsonElement`. + +## Reducers + +The reducers are a faithful port of the Go client's `reducers.go` and mirror +the canonical TypeScript reducers. They mutate state in place. The shared +fixtures under `types/test-cases/reducers/*.json` are the cross-language parity +gate — run them with `dotnet test`. The `resourceWatch` reducer is an +intentional stub (parity with the Rust and Go clients). + +## Testing + +Run by `dotnet test` (against both target frameworks, `net8.0` and `net9.0`). +The suite is **326 tests, all green on both TFMs** (0 skipped): + +1. **Shared reducer conformance** — `FixtureDrivenReducerTests` replays the 169 + cross-language reducer fixtures (`types/test-cases/reducers/*.json`). The + whole set counts as a single `[Theory]`. +2. **Shared wire round-trip corpus** — `TypesRoundTripFixtures` data-drives the + language-agnostic round-trip corpus under `types/test-cases/round-trips/*.json` + through the REAL serializer, asserting decode → re-encode is a byte-exact + fixed point. A `[Theory]` iterates every fixture; named `[Fact]` wrappers in + `TypesRoundTripTests` carry the cross-language matrix method names. +3. **Native unit tests** — `ClientTests` (full `AhpClient` over an in-memory + `MemTransport`, the port of Go's `client_test.go`), `HostsTests`, + `MultiHostClientTests`, `MultiHostStateMirrorTests`, `NativeReducerTests`, + `TypesRoundTripTests`, `ReconnectPolicyTests`, `ClientIdStoreTests`, + `FileClientIdStoreTests`, `TransportTests`, `WebSocketTransportTests`. +4. **Cross-implementation convergence** — `CrossImplementationConvergenceTests` + replays a session trace captured from an INDEPENDENT host (a separate + WebSocket host on the canonical TS `sessionReducer`) and asserts byte-identical + convergence (`serverSeq` + host-authoritative `modifiedAt`). + +Beyond CI, the **full `AhpClient` has been validated LIVE over a real WebSocket** +against a spec-faithful AHP host built on the canonical `sessionReducer`: the +real `initialize` request/response handshake, the snapshot in `InitializeResult`, +and the live `action` notification stream all converge with the host. (No +client in any language ships a real-socket integration test — they are all +mock-transport-based; this validation is run out-of-band rather than committed, +since it needs a Node host + the published package.) + +### Test-parity gate + +Two layers enforce **manifest parity** — the machine-checked cross-language +matrix subset. Both run [`scripts/check-test-parity.sh`](scripts/check-test-parity.sh) against +[`tests/parity-manifest.txt`](tests/parity-manifest.txt) — the expected parity +test methods in executable form — plus the count floor in +[`tests/MIN_TEST_COUNT`](tests/MIN_TEST_COUNT). + +The manifest currently enumerates **75 method names** and all 75 are present +(75/75). Read that precisely: it is the cross-language matrix *subset* that has +been transcribed into the manifest, **not** a literal mirror of the entire Swift +suite. Some Swift behaviors — notably a number of §H `MultiHostClient` +edge-cases and several sub-cases — are not yet enumerated in the manifest, so +"75/75 manifest parity" is a green gate, not a claim of complete Swift parity. +When you add tests that close one of those gaps, add the method name to the +manifest (and `--bump` the floor) so the matrix subset grows with the suite. + +- **CI (blocking):** `.github/workflows/ci.yml` runs the gate in COMPLETE mode — + it **fails the build while any manifest test is missing**, and the error + enumerates exactly which test methods to add (grouped by phase) and references + the parity manifest. Green only when every *manifest* method is present. +- **Local pre-push (ratchet):** the hook runs `--ratchet`, which blocks a push + only if the discrete `[Fact]`/`[Theory]` count drops below the floor (catches + deletions). It never blocks in-progress work, so the incremental commits that + climb toward parity push fine. + +Commands: + +- **Activate the local hook** (per-clone; git hooks are never shared — run once + from the repo root): `git config core.hooksPath scripts/git-hooks` +- **See what's still missing:** `clients/dotnet/scripts/check-test-parity.sh --list` +- **Raise the count floor after adding tests:** + `clients/dotnet/scripts/check-test-parity.sh --bump` + +Neither layer runs `dotnet test`; test *correctness* is enforced by the +`dotnet test` step in CI. The 169 shared reducer fixtures count as one `[Theory]`, +so they do not inflate the floor. + +## Architecture decisions + +- [`docs/decisions/sync.md`](docs/decisions/sync.md) + — the full menu of .NET synchronization primitives, the distinct concurrency + use cases in the client, which primitive each gets (`ConcurrentDictionary` + for the collections, `lock` for the `HostEntry` field-bundle, `SemaphoreSlim` + only for the WebSocket send path, `Channels`/`Interlocked`/`volatile` + elsewhere), and why the libraries multi-target `net8.0;net9.0` to use + `System.Threading.Lock` on .NET 9. +- [`docs/decisions/serialization.md`](docs/decisions/serialization.md) + — System.Text.Json (default, in-box, fastest) behind the `IAhpSerializer` + seam, versus Newtonsoft / lazy-DOM / validating options, across speed, + memory, lazy-vs-eager, validation, dependencies, and AOT. +- [`docs/decisions/reconnect.md`](docs/decisions/reconnect.md) + — hand-rolled exponential backoff (with opt-in jitter) versus + Polly / `Microsoft.Extensions.Resilience`, and why the core stays + dependency-free. + +These decision records live under `docs/decisions/` and are repo-only — they are not packed into any NuGet +package (only `README.md` is). + +## Releasing + +Sub-package releases publish the `Microsoft.AgentHostProtocol*` packages to +NuGet.org. This client does not ship its own publish automation; the +maintainers wire `dotnet pack` + `dotnet nuget push` into their own release +pipeline (e.g. the signed Azure DevOps / ESRP pipeline used for the Kotlin and +TypeScript packages). The `clients/dotnet/VERSION` ↔ `CHANGELOG.md` heading +match is enforced for every PR by `npm run verify:changelog`. + +## Out of scope + +JSON-Schema validation (a `Microsoft.AgentHostProtocol.Validation` decorator +over `IAhpSerializer`) and DI/extension helpers +(`Microsoft.AgentHostProtocol.Extensions`) are planned follow-ups, not part of +this client yet. diff --git a/clients/dotnet/AgentHostProtocol.slnx b/clients/dotnet/AgentHostProtocol.slnx new file mode 100644 index 00000000..6514a202 --- /dev/null +++ b/clients/dotnet/AgentHostProtocol.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/clients/dotnet/CHANGELOG.md b/clients/dotnet/CHANGELOG.md new file mode 100644 index 00000000..079cd41a --- /dev/null +++ b/clients/dotnet/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +All notable changes to the .NET client (`Microsoft.AgentHostProtocol*` +NuGet packages) are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +This client tracks the Agent Host Protocol spec on its own version line; see +[`release-metadata.json`](release-metadata.json) for the protocol versions +this release negotiates. + +## [Unreleased] + +### Added + +- `RootState` now exposes an optional `_meta` property bag + (`Dictionary? Meta`) for implementation-defined + agent-host metadata, such as a well-known `hostBuild` key carrying the + host's build version/commit/date. +- Full support for the per-session **annotations channel** + (`ahp-session://annotations`): the `AnnotationsState`, `Annotation`, + `AnnotationEntry`, and `AnnotationsSummary` wire types; the four + `annotations/{set,removed,entrySet,entryRemoved}` actions; and + `Reducers.ApplyToAnnotations`, a faithful port of the canonical reducer + (append-or-replace an annotation by id, drop a matching annotation, + append-or-replace an entry within an annotation, drop a matching entry; + unknown target ids are no-ops). Brings the .NET client to full + cross-language conformance parity on the annotations channel. +- `SessionSummary.Annotations` (and `PartialSessionSummary.Annotations`), + an optional `AnnotationsSummary` carrying annotation / entry counts for + badge UI without subscribing to the channel. +- `MessageAnnotationsAttachment` — the `annotations` variant of the + `MessageAttachment` union, referencing annotations on a session's + annotations channel. + +## [0.3.0] + +Implements AHP 0.3.0. + +### Added + +- `McpServerCustomization` now exposes the full MCP lifecycle: `Enabled`, + the discriminated `McpServerState` union + (`Starting`/`Ready`/`AuthRequired`/`Error`/`Stopped`), optional + `Channel` URI for the `mcp://` side-channel, and an optional `McpApp` + block carrying `AhpMcpUiHostCapabilities` for MCP Apps. +- `McpServerAuthRequiredState` variant carries `ProtectedResourceMetadata` + plus `Reason` / `RequiredScopes` / `Description` so the existing + `authenticate` command can drive per-server auth. +- The top-level `Customization` union now includes `McpServerCustomization` + — hosts MAY surface bare MCP servers directly rather than only inside a + plugin or directory. +- `SessionMcpServerStateChangedAction` and the matching + `Reducers.ApplyToSession` case — a narrow upsert of `State` + `Channel` + on an existing MCP server customization (located by id at the top level + or among a container's children; a no-op for an unknown id or a non-MCP + customization type). +- `ClientCapabilities` on `InitializeParams.Capabilities`, with the + `McpApps` capability. +- `ChangeKind` field on `Changeset` (well-known values: `session`, + `branch`, `uncommitted`, `turn`, `compare-turns`; unrecognized values + are preserved on the wire and fall back to a client default). +- `Status` and `Error` on `ChangesetOperation`, and the + `changeset/operationStatusChanged` action, tracking the + `idle → running → error` lifecycle of a changeset operation. +- `_meta` provider-metadata field on `AgentCustomization`. +- Optional `Changes` field on `SessionSummary` (`ChangesSummary` with + optional `Additions`, `Deletions`, and `Files` counts) summarising a + session's file-change footprint. + +### Changed + +- `ToolCallBase.ToolClientId` (a `string?`) is replaced by + `ToolCallBase.Contributor`, a `ToolCallContributor` discriminated union + with `ToolCallClientContributor { ClientId }` and + `ToolCallMcpContributor { CustomizationId }` variants. + `SessionToolCallStartAction` carries the new `Contributor` field, and the + reducer threads it through each tool-call transition. +- Renamed the `ChangesetSummary` type to `Changeset`. The on-the-wire shape + is unchanged. +- The `changesets` catalogue moved from `SessionSummary` to `SessionState`; + the `session/changesetsChanged` action now updates `state.Changesets` + directly instead of `state.Summary.Changesets`. +- `Reducers.ApplyToChangeset` is now fully implemented (previously a no-op + stub), so `changeset/*` actions fold into `ChangesetState`. Brings the + .NET client to full cross-language conformance parity on the changeset + channel. + +### Removed + +- Removed the `Additions`, `Deletions`, and `Files` fields from the former + `ChangesetSummary`. Aggregate counts now live on `SessionSummary.Changes`; + per-changeset views derive their own totals from `ChangesetState.Files`. + +## [0.1.0] + +Initial release of the .NET client. + +### Added + +- **`Microsoft.AgentHostProtocol.Abstractions`** — the wire types generated + from the canonical TypeScript protocol definitions (state, actions, + commands, notifications, JSON-RPC messages, errors, and version + constants), the `StringOrMarkdown` helper, the `AhpUnion` discriminated- + union support and `WireEnumConverter`, and the `ITransport` / + `IAhpSerializer` interface seams. +- **`Microsoft.AgentHostProtocol`** — the async JSON-RPC `AhpClient`, the + pure state reducers (`Reducers.ApplyToRoot` / `ApplyToSession` / + `ApplyToTerminal` / `ApplyToChangeset`), the default + `SystemTextJsonAhpSerializer`, the per-URI subscription fan-out, and the + `MultiHostClient` runtime under `Microsoft.AgentHostProtocol.Hosts`. +- **`Microsoft.AgentHostProtocol.WebSockets`** — a `ClientWebSocket`-based + `ITransport` implementation. diff --git a/clients/dotnet/Directory.Build.props b/clients/dotnet/Directory.Build.props new file mode 100644 index 00000000..df7db7eb --- /dev/null +++ b/clients/dotnet/Directory.Build.props @@ -0,0 +1,44 @@ + + + + + + 13 + enable + disable + true + + $(NoWarn);CS1591 + false + + + + + Microsoft Corporation + Microsoft Corporation + Agent Host Protocol + © Microsoft Corporation. All rights reserved. + MIT + https://github.com/microsoft/agent-host-protocol + https://github.com/microsoft/agent-host-protocol + git + agent-host-protocol;ahp;ai;agent;jsonrpc + README.md + true + true + snupkg + + 0.1.0 + + + diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md new file mode 100644 index 00000000..4ce8d246 --- /dev/null +++ b/clients/dotnet/README.md @@ -0,0 +1,94 @@ +# Agent Host Protocol — .NET client + +The [Agent Host Protocol](https://microsoft.github.io/agent-host-protocol/) +(AHP) client for .NET: the wire types, the pure state reducers, an async +JSON-RPC client, a `ClientWebSocket` transport, and the multi-host runtime. + +## Install + +```bash +dotnet add package Microsoft.AgentHostProtocol +dotnet add package Microsoft.AgentHostProtocol.WebSockets # ClientWebSocket transport +``` + +| Package | Use it for | +| --- | --- | +| `Microsoft.AgentHostProtocol.Abstractions` | Wire types + reducers' data contracts + the `ITransport` / `IAhpSerializer` interfaces. No I/O, no dependencies. Reference this alone to parse / construct AHP messages or implement a transport. | +| `Microsoft.AgentHostProtocol` | The async `AhpClient`, the pure reducers, the default System.Text.Json serializer, and the `MultiHostClient`. | +| `Microsoft.AgentHostProtocol.WebSockets` | A `System.Net.WebSockets.ClientWebSocket`-based `ITransport`. | + +(`Microsoft.AgentHostProtocol` references `.Abstractions` transitively, so most +consumers add the two packages above.) + +## Quickstart + +```csharp +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.WebSockets; + +await using var transport = await WebSocketTransport.ConnectAsync(new Uri("ws://localhost:5172")); +var client = AhpClient.Connect(transport); + +await client.InitializeAsync( + clientId: "ahp-dotnet-example", + protocolVersions: ProtocolVersion.Supported, + initialSubscriptions: new[] { ProtocolVersion.RootResourceUri }); + +var root = client.AttachSubscription(ProtocolVersion.RootResourceUri); +await foreach (var evt in root.Events()) +{ + Console.WriteLine(evt); +} +``` + +The pure reducers need no client at all: + +```csharp +var state = new SessionState { /* ... */ }; +Reducers.ApplyToSession(state, action); // mutates `state` in place +``` + +See [`examples/`](examples/) for runnable `ConnectWs` and `ReducersDemo` +console apps. + +## Code generation + +The wire types under +`src/AgentHostProtocol.Abstractions/Generated/*.generated.cs` are generated +from the canonical TypeScript protocol definitions in `types/`. Do not edit +them by hand. From the repository root: + +```bash +npm install +npm run generate:dotnet +``` + +CI re-runs the generator and fails on any diff, so generated sources always +match the protocol definitions. Hand-written support lives alongside the +generated files (`Json/`, `Transport/`) and in the `Microsoft.AgentHostProtocol` +project. + +## Serialization is pluggable + +The client talks to the JSON engine through the `IAhpSerializer` seam; the +default is `SystemTextJsonAhpSerializer` (System.Text.Json). An alternative +implementation can swap the engine or decorate it with JSON-Schema validation +(against the schemas the repository generates under `schema/`) without changing +the client or transport. + +## Releasing + +1. Bump [`VERSION`](VERSION). +2. From the repo root, run `npm run generate:metadata` and commit the updated + [`release-metadata.json`](release-metadata.json). +3. Rotate the `## [Unreleased]` section of [`CHANGELOG.md`](CHANGELOG.md) to + `## [X.Y.Z]`. +4. Merge to `main`, then publish the `Microsoft.AgentHostProtocol*` packages + to NuGet.org. This client does not ship its own publish automation — wire + `dotnet pack` + `dotnet nuget push` into whichever release pipeline the + maintainers use for their other clients (e.g. the signed Azure DevOps / + ESRP pipeline that publishes the Kotlin and TypeScript packages). + +## License + +MIT diff --git a/clients/dotnet/VERSION b/clients/dotnet/VERSION new file mode 100644 index 00000000..0d91a54c --- /dev/null +++ b/clients/dotnet/VERSION @@ -0,0 +1 @@ +0.3.0 diff --git a/clients/dotnet/docs/decisions/reconnect.md b/clients/dotnet/docs/decisions/reconnect.md new file mode 100644 index 00000000..b99a1b96 --- /dev/null +++ b/clients/dotnet/docs/decisions/reconnect.md @@ -0,0 +1,80 @@ +# Reconnect / retry strategy + +- **Status:** Accepted +- **Scope:** `clients/dotnet` — the multi-host reconnect supervisor + (`Hosts/MultiHostClient.cs`, `ReconnectPolicy`). +- **Audience:** maintainers of the .NET client. Repo-only; not shipped in any + NuGet package. + +## Context + +When a host's transport drops unexpectedly, `MultiHostClient` supervises a +reconnect with exponential backoff (`ReconnectPolicy`: initial/max backoff, +multiplier, max attempts, reset-on-success), emitting `Reconnecting` / `Failed` +host-state events and preserving the client id across attempts. + +.NET has a first-class retry/resilience stack now — +**Polly v8** and **`Microsoft.Extensions.Resilience`** / +**`Microsoft.Extensions.Http.Resilience`** (the latter's +`AddStandardResilienceHandler()` gives an HttpClient a pre-built pipeline of +retry-with-jitter + circuit-breaker + timeout in one line). The question is +whether the client should depend on that stack or keep its small hand-rolled +loop. + +## Dimensions considered + +1. **Dependency footprint** — the libraries currently have **zero** NuGet + dependencies. +2. **Fit** — is this an HttpClient call, or something else? +3. **Cross-client parity** — how the other AHP clients reconnect. +4. **Features** — retry, jitter, circuit breaker, timeout, telemetry. +5. **Consumer extensibility** — can a consumer who *does* use Polly add their + own resilience without the client forcing it? + +## Options + +| Option | Deps | Fit | Notes | +| --- | --- | --- | --- | +| **Hand-rolled exponential backoff** (current) | none | exact | Lives inside the supervisor alongside host-state transitions and client-id persistence. Matches the Go/Rust/TS/Swift/Kotlin clients, which all hand-roll reconnect. | +| `Microsoft.Extensions.Http.Resilience` (`AddStandardResilienceHandler`) | +Polly +Extensions | **poor** | Built for `HttpClient` message pipelines. The AHP transport is a WebSocket/abstract `ITransport`, not an `HttpClient` call — this doesn't apply. | +| `Microsoft.Extensions.Resilience` / Polly v8 `ResiliencePipeline` | +Polly +Extensions | partial | `ResiliencePipeline` can wrap an arbitrary delegate, so it *could* drive the reconnect. But it adds dependencies, and the reconnect is intertwined with host-state events, client-id persistence, and supervisor lifetime that a generic retry pipeline doesn't model cleanly. | + +## Decision + +**Keep the hand-rolled exponential backoff in the core**, and adopt the one +best-practice the resilience libraries embody — **exponential backoff with +jitter** — as a dependency-free, opt-in `ReconnectPolicy.Jitter`. + +- **Zero dependencies** stays a hard goal for the libraries; pulling in Polly + + `Microsoft.Extensions.*` for a ~30-line backoff loop is a bad trade. +- **Parity:** every other AHP client hand-rolls reconnect with the same + policy shape; matching them keeps behavior consistent across the family. +- **Fit:** the reconnect is a transport-reconnect state machine, not an + HttpClient call — the standard HTTP resilience handler does not apply. +- **Jitter** (`ReconnectPolicy.Jitter`, a 0–1 fraction, default **0** for + parity) randomizes each backoff by ±that fraction to avoid reconnect storms + when many hosts drop at once. `0.2` is a reasonable production value. This + captures the resilience libraries' headline recommendation without their + dependency. + +### Consumer seam + +A consumer who already standardizes on Polly / `Microsoft.Extensions.Resilience` +is **not** blocked: `HostConfig.TransportFactory` is the delegate that opens a +transport, so they can wrap their own resilience pipeline (retry, circuit +breaker, timeout) around transport creation. The client doesn't bake a policy +in; it provides the seam. + +## Consequences + +- Core libraries stay dependency-free. +- Jitter is available immediately and tested (`ReconnectPolicyTests`). +- Advanced strategies (circuit breaker, per-attempt timeout, telemetry) are a + documented future option — most naturally as an *optional* resilience- + integration package (analogous to the planned validation package in + [the serialization decision](serialization.md)), not a core dependency. + +## References + +- [Build resilient HTTP apps: key patterns — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience) +- [Retry resilience strategy — Polly](https://www.pollydocs.org/strategies/retry.html) diff --git a/clients/dotnet/docs/decisions/serialization.md b/clients/dotnet/docs/decisions/serialization.md new file mode 100644 index 00000000..55647573 --- /dev/null +++ b/clients/dotnet/docs/decisions/serialization.md @@ -0,0 +1,124 @@ +# JSON serialization engine + +- **Status:** Accepted +- **Scope:** `clients/dotnet` (the `Microsoft.AgentHostProtocol*` packages) +- **Audience:** maintainers of the .NET client. Repo-only; not shipped in any + NuGet package. + +## Context + +The client has to turn AHP wire messages (JSON-RPC framing wrapping protocol +state, actions, commands, notifications) into typed objects and back. The +reducers operate on fully-typed state, and the protocol uses string-keyed +discriminated unions, a `string | { markdown }` scalar, and bitset enums — so +the serializer has to support custom converters. + +Two questions had to be answered: **which engine**, and **how coupled** the +rest of the client is to it. The engine is pluggable behind the +`IAhpSerializer` seam (`Encode`/`Decode`/`DecodeMessage`); this ADR records why +the default is System.Text.Json and what the seam does and does not decouple. + +## Dimensions considered + +1. **Throughput** — serialize/deserialize speed on the hot path (every event). +2. **Memory / allocations** — GC pressure per message. +3. **Eager vs lazy (late binding)** — materialize the whole POCO graph every + time, or bind fields on demand from a DOM. +4. **Validation** — can inbound frames be checked against the JSON Schema the + repo already generates under `schema/`? +5. **Dependency footprint** — does it add a NuGet dependency, or is it in-box? +6. **AOT / trimming** — reflection-based vs source-generated; Native AOT and + trimming friendliness. +7. **Strictness / standards** — strict-by-default (good for a wire protocol) + vs lenient. +8. **Polymorphism / custom converters** — support for the protocol's + discriminated unions, `StringOrMarkdown`, and bitset enums. +9. **Ecosystem familiarity / migration cost.** +10. **Cross-client consistency** — how the other AHP clients bind their wire + types. + +## Options considered + +### Engines + +| Option | Throughput | Allocations | Eager/Lazy | Deps | AOT | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| **System.Text.Json (POCO)** ✅ | Highest | Lowest (`Span`/UTF-8) | Eager | **In-box** (net8/net9) | Source-gen capable | Strict by default; Microsoft's greenfield recommendation. | +| System.Text.Json + source generation | Highest (+startup, +AOT) | Lowest | Eager | In-box | **Best** | A `JsonSerializerContext` enhancement we can add later for AOT/trimming without changing the public API. | +| Newtonsoft.Json (Json.NET) | ~20–35% slower; ~3× more allocations on .NET 10 | High (reflection, no `Span`) | Eager or `JObject` (lazy, mutable) | **+dependency** | Reflection (AOT-hostile) | Lenient by default; ubiquitous, but a dependency and slower. | +| Lazy DOM — `JsonNode` / `JsonElement` (STJ) or `JObject` (Newtonsoft) | n/a (no bind) | Low for partial reads | **Lazy** | In-box (STJ) | ok | A *different consumption model*: expose untyped views instead of typed state. Reducers can't run on it without materializing. | +| Utf8Json / Jil / other high-perf | Very high | Very low | Eager | +dependency | varies | Effectively unmaintained; not worth the dependency/risk for a JSON wire protocol. | +| MessagePack / binary | Very high | Very low | Eager | +dependency | ok | Not JSON — the AHP wire format is JSON-RPC, so out of scope. | + +### Validation libraries (for a future "validated" mode) + +| Option | License | Notes | +| --- | --- | --- | +| **JsonSchema.Net (json-everything)** | MIT | Spec-compliant JSON Schema validator; the natural fit to validate against the repo's generated `schema/*.schema.json`. | +| NJsonSchema | MIT | Validation + code-gen; heavier surface than we need. | +| Newtonsoft.Json.Schema | **Commercial (paid above a free-use threshold)** | Disqualifying for an in-box, permissively-licensed library. | + +## Decision + +**Default engine: System.Text.Json, eager POCO binding, behind the +`IAhpSerializer` seam.** + +Rationale, against the dimensions: + +- **Throughput + memory:** STJ is the fastest, lowest-allocation option — it is + built on `Span`/`ReadOnlySpan` and is ~20–35% faster with ~3× fewer + allocations than Newtonsoft on modern .NET. This matters on the per-event hot + path. +- **Dependencies:** STJ is **in the shared framework** for net8/net9 — the + packages stay at **zero NuGet dependencies**, which is a hard goal for this + library. +- **AOT / trimming:** STJ supports source generation, giving us a clean, + non-breaking path to Native-AOT/trimming friendliness later. +- **Strictness:** strict-by-default is correct for a wire protocol — a + malformed or unexpected frame should fail loudly, not be silently coerced. +- **Custom shapes:** the protocol's discriminated unions, `StringOrMarkdown`, + and bitset enums are handled by hand-written converters + (`UnionConverter`, `WireEnumConverter`, `StringOrMarkdownConverter`) — + which any engine would require, and which STJ supports cleanly. +- **Cross-client consistency:** every other AHP client bakes serialization into + its generated wire types (Go `json` tags, Rust `serde`, Kotlin + `@Serializable`, Swift `Codable`, TS native). The generated C# types are + likewise STJ-attributed — consistent with the family. + +### What the `IAhpSerializer` seam does and does not decouple + +- It **does** make the *transport/client* engine swappable and lets a + **validating decorator** wrap the default serializer (see below). +- It **does not** make the *generated types* serializer-agnostic: they carry + STJ attributes by design (mirroring how every other client bakes its + serializer into its types). A true engine swap (e.g. to Newtonsoft) would + mean re-emitting the types for that engine — tractable since they're + generated, but STJ stays the one default. + +### Deferred, on purpose + +- **Validation ("validated vs not"):** a future opt-in + `Microsoft.AgentHostProtocol.Validation` package will decorate + `IAhpSerializer` and validate inbound frames against the repo's generated + `schema/*.schema.json` using **JsonSchema.Net (json-everything, MIT)**. Kept + out of the core so the default path stays zero-dependency and fast. +- **Lazy / late-binding surface:** if a consumer needs to inspect frames + without materializing typed state (a proxy/pass-through), that is a separate + read-only `JsonNode`/`JsonElement` surface — not a drop-in serializer swap, + because the reducers require typed state. +- **Source generation:** add a `JsonSerializerContext` for AOT/trimming and a + further perf bump when there is a concrete AOT consumer. + +## Consequences + +- The default path is fast, allocation-light, and dependency-free. +- A different engine or a validating layer can be added behind `IAhpSerializer` + without touching the client or transport. +- Consumers who want JSON-Schema validation opt into a separate package; the + core never pays for it. + +## References + +- [Migrate from Newtonsoft.Json to System.Text.Json — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft) +- [Benchmarking System.Text.Json vs Newtonsoft.Json on .NET 10 — jkrussell.dev](https://jkrussell.dev/blog/system-text-json-vs-newtonsoft-json-benchmark/) +- [Newtonsoft.Json.Schema licensing (commercial)](https://www.newtonsoft.com/jsonschema) diff --git a/clients/dotnet/docs/decisions/sync.md b/clients/dotnet/docs/decisions/sync.md new file mode 100644 index 00000000..40bb5041 --- /dev/null +++ b/clients/dotnet/docs/decisions/sync.md @@ -0,0 +1,129 @@ +# Synchronization & concurrency primitives + +- **Status:** Accepted +- **Scope:** `clients/dotnet` (the `Microsoft.AgentHostProtocol*` packages) +- **Audience:** maintainers of the .NET client. This document is repo-only; it + is not shipped in any NuGet package. + +## Context + +The client has several pieces of shared, concurrently-accessed state: + +- the async JSON-RPC client's pending-request table and subscription registry + (`AhpClient`); +- the multi-host registry, the per-host bookkeeping record (`HostEntry`), the + client-id store, and the optional `MultiHostStateMirror` + (`Hosts/MultiHostClient.cs`); +- the WebSocket transport's send path (`WebSocketTransport`). + +An early version reflexively translated the Go client's `sync.RWMutex` to +`SemaphoreSlim` + `await WaitAsync()` for **all** of this state — which made +pure in-memory accessors `async` for no reason. `SemaphoreSlim` is the right +tool only when you must `await` **while holding** the lock; none of those +critical sections did. This ADR records the primitive chosen for each access +pattern and why, so the choice isn't re-litigated (or re-broken) later. + +## Options considered + +The full menu of .NET synchronization options (see the +[Microsoft Learn overview](https://learn.microsoft.com/en-us/dotnet/standard/threading/overview-of-synchronization-primitives)), +and where each lands for this client: + +| Option | Category | Verdict here | +| --- | --- | --- | +| `lock` / `Monitor` | exclusive lock | **Used** — `HostEntry` field-bundle, channel-list append. Cannot be held across `await`. | +| `System.Threading.Lock` (.NET 9 / C# 13) | exclusive lock | **Used on net9** via a conditional alias — ~25% faster than `Monitor` (compiler emits `Lock.EnterScope()`). net9.0+ only; net8 falls back to `Monitor`. | +| `Mutex` | cross-**process** lock | No — everything is in-process; `Mutex` is a kernel object, far heavier. | +| `SpinLock` | busy-wait exclusive (struct) | No — only wins for nanosecond-scale sections on hot paths; ours aren't that hot, and it's easy to misuse. | +| `SemaphoreSlim` | count-limited / async-capable lock | **Used** — WebSocket send (the one place we `await` *inside* the critical section). | +| `Semaphore` | count-limited, cross-process (kernel) | No — kernel object; `SemaphoreSlim` suffices in-process. | +| `ReaderWriterLockSlim` | read-heavy maps, non-trivial sections | No — loses to `ConcurrentDictionary` under load; recursion/async footguns. | +| `ReaderWriterLock` (legacy) | read/write lock | No — deprecated. | +| `ManualResetEventSlim` / `AutoResetEvent` / `EventWaitHandle` | thread signaling | No — we coordinate via `TaskCompletionSource` and `Channels` (async), not thread events. | +| `Barrier` / `CountdownEvent` | phase / fan-in coordination | No — not our pattern. | +| `Interlocked` | single-value atomics (CAS/increment) | **Used** — request-id and client-seq counters. | +| `Volatile` / `volatile` | single-field visibility | **Used** — shutdown flag (`Volatile.Read`), client-id-store reference. | +| `Lazy` / `LazyInitializer` | thread-safe one-time init | No — no expensive one-time init to guard. | +| `ConcurrentDictionary` | concurrent keyed map | **Used** — host registry, client-id store, state mirror. Lock-free reads; atomic `TryAdd`/`GetOrAdd`/`AddOrUpdate`. | +| `ConcurrentQueue` / `ConcurrentStack` / `ConcurrentBag` | lock-free FIFO/LIFO/bag | No — our producer/consumer fan-out is `Channels`. | +| `BlockingCollection` | blocking producer/consumer | No — superseded by `System.Threading.Channels` for async backpressure. | +| `ImmutableDictionary` + `ImmutableInterlocked` | read-mostly, free consistent snapshot | No — more write allocation than `ConcurrentDictionary`; overkill for a small, low-write registry. | +| `FrozenDictionary` / `FrozenSet` (.NET 8) | read-only after build, fastest reads | No — our maps mutate at runtime (hosts come and go); frozen sets are build-once. | +| `System.Threading.Channels` | async producer/consumer | **Used** — subscription/event fan-out (bounded, drop-oldest). | +| `TaskCompletionSource` | one-shot async completion | **Used** — request/response correlation and the client "done" signal. | + +## Distinct concurrency use cases + +There isn't a single locking pattern — the client has **several distinct +concurrency use cases**, and each gets the primitive that fits it. That is the +whole point of this ADR: not "what's our lock," but "what's the right tool for +each problem." + +| # | Use case | Where | Primitive | +| --- | --- | --- | --- | +| 1 | Concurrent keyed map (independent entries) | host registry, client-id store, state mirror | `ConcurrentDictionary` | +| 2 | Update/read a small bundle of related fields **atomically** | `HostEntry` (`_client`/`_state`/`_protoVer`/`_generation`/`_updatedAt`) | `lock` | +| 3 | Append to + snapshot a list | subscription/event subscriber lists | `lock` | +| 4 | Serialize an **awaited** I/O call (no concurrent sends) | `WebSocketTransport.SendAsync` | `SemaphoreSlim` | +| 5 | Single-value atomic counter | JSON-RPC request id, client sequence | `Interlocked` | +| 6 | Publish a single field / flag visibly | shutdown flag, client-id-store reference, current per-host client | `Volatile` / `volatile` (a reference read is atomic, so no lock is needed for one field) | +| 7 | Producer/consumer fan-out with backpressure | subscription + host event delivery | `System.Threading.Channels` | +| 8 | Request/response correlation by id | `AhpClient` pending-request table | `ConcurrentDictionary>` | +| 9 | One-shot completion signal | client `Completion` / `Done` | `TaskCompletionSource` | + +## Decision + +Pick the primitive that matches the **access pattern**, not a single +one-size-fits-all lock. + +| State | Access pattern | Primitive | +| --- | --- | --- | +| Host registry (`MultiHostClient._hosts`) | read-heavy; `TryAdd`/`TryRemove`/`TryGet`/snapshot-all | **`ConcurrentDictionary`** — `TryAdd` is the add-if-absent done atomically, removing both the lock and the check-then-act race. | +| `InMemoryClientIdStore` | single-key load/store | **`ConcurrentDictionary`** — lock-free; the `IClientIdStore` *interface* stays async because a real store does I/O. | +| `MultiHostStateMirror` (4 maps) | independent single-key put/get + per-host drop | **`ConcurrentDictionary`** per map. | +| `HostEntry` fields (`_client`/`_state`/`_protoVer`/`_generation`/`_updatedAt`) | a small bundle read and written **as a group** | **`lock`** — a `ConcurrentDictionary` cannot express "set these three fields atomically"; writes are rare connect/disconnect events. | +| Event/subscription channel lists | append + snapshot-iterate, near-zero contention | **`lock`** around a `List`. | +| WebSocket send | **awaits** `SendAsync` while holding | **`SemaphoreSlim`** — the one genuine async-lock. | +| Request-id / client-seq counters | single-value increment | **`Interlocked`**. | +| Client-id-store reference swap | single-field publish | **`volatile`**. | + +### `System.Threading.Lock` on .NET 9, `Monitor` on .NET 8 + +The packages multi-target `net8.0;net9.0`. The `lock`-based fields use a +conditional type alias so each runtime gets its best lock with no change to the +`lock (…) { … }` statements: + +```csharp +// GlobalUsings.cs +#if NET9_0_OR_GREATER +global using Gate = System.Threading.Lock; // ~25% faster under contention +#else +global using Gate = System.Object; // classic Monitor +#endif +``` + +```csharp +private readonly Gate _gate = new(); +// ... +lock (_gate) { /* … */ } // emits Lock.EnterScope() on net9, Monitor on net8 +``` + +NuGet selects the `net9.0` assets for .NET 9+ consumers automatically; .NET 8 +consumers get the `net8.0` assets. The target framework stays `net8.0` (the +current LTS) for maximum supported reach. + +## Consequences + +- Reads of host state (`Host`/`Hosts`) are synchronous and lock-free; the + only `async` methods left are the ones that actually do I/O + (connect/initialize/send/receive/shutdown). +- One small `lock` remains where it is genuinely correct (`HostEntry`), and one + `SemaphoreSlim` remains where it is genuinely correct (WebSocket send). +- A `net9.0` build is validated in CI; the lock semantics are identical across + TFMs, so behavior is unchanged — only the lock implementation differs. + +## References + +- [Best Practices for Using ConcurrentDictionary — Eli Arbel](https://arbel.net/2013/02/03/best-practices-for-using-concurrentdictionary/) +- [ConcurrentDictionary vs ReaderWriterLockSlim — aspnet/Caching#242](https://github.com/aspnet/Caching/issues/242) +- [The `lock` statement / `System.Threading.Lock` — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock) diff --git a/clients/dotnet/examples/ConnectWs/ConnectWs.csproj b/clients/dotnet/examples/ConnectWs/ConnectWs.csproj new file mode 100644 index 00000000..1228facb --- /dev/null +++ b/clients/dotnet/examples/ConnectWs/ConnectWs.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + Exe + false + false + + Major + + + + + + + + diff --git a/clients/dotnet/examples/ConnectWs/Program.cs b/clients/dotnet/examples/ConnectWs/Program.cs new file mode 100644 index 00000000..e5528f2d --- /dev/null +++ b/clients/dotnet/examples/ConnectWs/Program.cs @@ -0,0 +1,73 @@ +// Connect to an AHP server over WebSocket, run the initialize handshake, +// attach a root subscription, and print every inbound event as JSON until +// the connection drops or CTRL+C is pressed. +// +// Usage: dotnet run --project examples/ConnectWs -- ws://host:port +#nullable enable + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.WebSockets; + +if (args.Length != 1) +{ + Console.Error.WriteLine("usage: ConnectWs ws://host:port"); + return 2; +} + +var url = new Uri(args[0]); + +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + +WebSocketTransport transport; +try +{ + transport = await WebSocketTransport.ConnectAsync(url, cancellationToken: cts.Token); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"connect: {ex.Message}"); + return 1; +} + +await using var client = await AhpClient.ConnectAsync(transport); + +InitializeResult init; +try +{ + init = await client.InitializeAsync( + "ahp-dotnet-example", + ProtocolVersion.Supported, + new[] { ProtocolVersion.RootResourceUri }, + cts.Token); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"initialize: {ex.Message}"); + return 1; +} + +Console.Error.WriteLine($"negotiated protocol version: {init.ProtocolVersion}"); + +var sub = client.AttachSubscription(ProtocolVersion.RootResourceUri); +var options = new JsonSerializerOptions { WriteIndented = true }; + +try +{ + await foreach (var ev in sub.Events.ReadAllAsync(cts.Token)) + { + var json = JsonSerializer.Serialize(ev, options); + Console.WriteLine($"{ev.GetType().Name}:"); + Console.WriteLine(json); + Console.WriteLine(); + } +} +catch (OperationCanceledException) { /* CTRL+C */ } + +sub.Close(); +await client.ShutdownAsync(CancellationToken.None); +return 0; diff --git a/clients/dotnet/examples/ReducersDemo/Program.cs b/clients/dotnet/examples/ReducersDemo/Program.cs new file mode 100644 index 00000000..b6096251 --- /dev/null +++ b/clients/dotnet/examples/ReducersDemo/Program.cs @@ -0,0 +1,78 @@ +// Applies a handful of session actions to an empty SessionState to illustrate +// the public reducer API. Port of clients/go/examples/reducers_demo/main.go. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.AgentHostProtocol; + +var state = new SessionState +{ + Summary = new SessionSummary + { + Resource = "ahp-session:/demo", + Provider = "demo", + Title = "Demo", + Status = SessionStatus.Idle, + CreatedAt = 1, + }, + Lifecycle = SessionLifecycle.Ready, + Turns = new System.Collections.Generic.List(), +}; + +var actions = new List +{ + new StateAction(new SessionTurnStartedAction + { + Type = ActionType.SessionTurnStarted, + TurnId = "t1", + Message = new Message + { + Text = "Hello!", + Origin = System.Text.Json.JsonDocument.Parse("""{"role":"user"}""").RootElement, + }, + }), + new StateAction(new SessionResponsePartAction + { + Type = ActionType.SessionResponsePart, + TurnId = "t1", + Part = new ResponsePart(new MarkdownResponsePart + { + Kind = ResponsePartKind.Markdown, + Id = "p1", + Content = "Hi ", + }), + }), + new StateAction(new SessionDeltaAction + { + Type = ActionType.SessionDelta, + TurnId = "t1", + PartId = "p1", + Content = "there!", + }), + new StateAction(new SessionTurnCompleteAction + { + Type = ActionType.SessionTurnComplete, + TurnId = "t1", + }), +}; + +foreach (var action in actions) +{ + var outcome = Reducers.ApplyToSession(state, action); + Console.WriteLine($"applied {action.Value?.GetType().Name} → {OutcomeName(outcome)}"); +} + +var options = new JsonSerializerOptions { WriteIndented = true }; +var pretty = JsonSerializer.Serialize(state, options); +Console.WriteLine("final state:"); +Console.WriteLine(pretty); + +static string OutcomeName(ReduceOutcome o) => o switch +{ + ReduceOutcome.Applied => "Applied", + ReduceOutcome.NoOp => "NoOp", + ReduceOutcome.OutOfScope => "OutOfScope", + _ => "?", +}; diff --git a/clients/dotnet/examples/ReducersDemo/ReducersDemo.csproj b/clients/dotnet/examples/ReducersDemo/ReducersDemo.csproj new file mode 100644 index 00000000..dbad2dca --- /dev/null +++ b/clients/dotnet/examples/ReducersDemo/ReducersDemo.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + Exe + false + false + + Major + + + + + + + diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json new file mode 100644 index 00000000..691df56b --- /dev/null +++ b/clients/dotnet/release-metadata.json @@ -0,0 +1,8 @@ +{ + "client": "dotnet", + "packageVersion": "0.3.0", + "supportedProtocolVersions": [ + "0.4.0", + "0.3.0" + ] +} diff --git a/clients/dotnet/scripts/check-test-parity.sh b/clients/dotnet/scripts/check-test-parity.sh new file mode 100755 index 00000000..38daedd4 --- /dev/null +++ b/clients/dotnet/scripts/check-test-parity.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# .NET test-parity gate. +# +# Two complementary checks against the .NET test suite: +# 1. Count floor (clients/dotnet/tests/MIN_TEST_COUNT) — a ratchet so the number +# of discrete [Fact]/[Theory] methods never regresses (catches deletions). +# 2. Parity manifest (clients/dotnet/tests/parity-manifest.txt) — the expected +# parity test methods (executable form of the master matrix). Any manifest +# entry whose method name is absent from the test sources is "missing". +# +# Modes: +# check-test-parity.sh COMPLETE - fail if ANY manifest test is +# missing; enumerate the missing ones. Used as +# the BLOCKING CI gate (.github/workflows/ci.yml). +# check-test-parity.sh --ratchet RATCHET - fail only if the method count +# dropped below the floor; never blocks +# in-progress work. Used by the local pre-push hook. +# check-test-parity.sh --list report present/missing, never fail. +# check-test-parity.sh --bump raise the floor to the current method count. +# +# The parity contract: the .NET client mirrors the cross-language test matrix. +# The expected parity test methods are enumerated in the manifest below; the +# count floor guards against silent deletions. See clients/dotnet/AGENTS.md +# ("Test-parity gate") for the prose contract. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TEST_DIR="$ROOT/clients/dotnet/tests/AgentHostProtocol.Tests" +FLOOR_FILE="$ROOT/clients/dotnet/tests/MIN_TEST_COUNT" +MANIFEST="$ROOT/clients/dotnet/tests/parity-manifest.txt" + +[ -d "$TEST_DIR" ] || { echo "check-test-parity: missing test dir $TEST_DIR" >&2; exit 1; } +[ -f "$FLOOR_FILE" ] || { echo "check-test-parity: missing floor file $FLOOR_FILE" >&2; exit 1; } +[ -f "$MANIFEST" ] || { echo "check-test-parity: missing manifest $MANIFEST" >&2; exit 1; } + +# Restrict to source .cs and skip build output (bin/obj) — recursing compiled +# binaries makes grep read every byte for non-matching patterns (minutes, not ms). +GREP_SCOPE=(--include='*.cs' --exclude-dir=bin --exclude-dir=obj) + +method_count() { { grep -rhE "${GREP_SCOPE[@]}" '^[[:space:]]*\[(Fact|Theory)\]' "$TEST_DIR" 2>/dev/null || true; } | wc -l | tr -d ' '; } + +# Populate the missing/present arrays from the manifest. +missing=() # "phase|suite|method|label" +present_manifest=0 +total_manifest=0 +while IFS= read -r raw; do + line="${raw%%#*}" # strip comments + [ -z "${line// }" ] && continue # skip blank + IFS='|' read -r method suite phase label </dev/null; then + present_manifest=$((present_manifest + 1)) + else + missing+=("$phase|$suite|$method|$label") + fi +done < "$MANIFEST" + +COUNT="$(method_count)" +FLOOR="$(tr -cd '0-9' < "$FLOOR_FILE")"; : "${FLOOR:=0}" +missing_count=${#missing[@]} + +enumerate_missing() { + local target_phase="$1" ph su me la shown=0 + for phase in 1 2; do + [ -n "$target_phase" ] && [ "$target_phase" != "$phase" ] && continue + local header_done=0 + for entry in ${missing[@]+"${missing[@]}"}; do + IFS='|' read -r ph su me la <&2; header_done=1 + fi + printf ' [ ] %-55s %s\n' "$su.$me" "($la)" >&2 + shown=$((shown + 1)) + done + done + if [ "$shown" = 0 ]; then echo " (none)" >&2; fi + return 0 +} + +case "${1:-}" in + --bump) + if [ "$COUNT" -gt "$FLOOR" ]; then + printf '%s\n' "$COUNT" > "$FLOOR_FILE" + echo "check-test-parity: floor raised $FLOOR -> $COUNT" + else + echo "check-test-parity: floor unchanged ($FLOOR); current $COUNT is not higher" + fi + exit 0 + ;; + --list) + echo "check-test-parity: $COUNT test methods (floor $FLOOR); parity $present_manifest/$total_manifest present, $missing_count missing" + [ "$missing_count" -gt 0 ] && { echo "missing parity tests:" >&2; enumerate_missing ""; } + exit 0 + ;; + --ratchet) + if [ "$COUNT" -lt "$FLOOR" ]; then + { + echo "check-test-parity: FAIL - .NET test methods regressed: $COUNT < floor $FLOOR" + echo " A [Fact]/[Theory] was removed. Restore it, or - if intentional - lower" + echo " clients/dotnet/tests/MIN_TEST_COUNT in the same commit and explain why." + echo " Parity contract: clients/dotnet/AGENTS.md (\"Test-parity gate\")." + echo " Parity tests still missing ($missing_count of $total_manifest):" + enumerate_missing "" + } >&2 + exit 1 + fi + echo "check-test-parity: ok - $COUNT methods >= floor $FLOOR; parity $present_manifest/$total_manifest present, $missing_count remaining (see plan; run --list to enumerate)" + exit 0 + ;; + "" ) + if [ "$missing_count" -gt 0 ]; then + { + echo "check-test-parity: FAIL - .NET client is not at test parity: $missing_count of $total_manifest expected tests are missing ($present_manifest present)." + echo " Parity contract: clients/dotnet/AGENTS.md (\"Test-parity gate\")." + echo " Add the following test methods (named per the parity manifest," + echo " clients/dotnet/tests/parity-manifest.txt):" + enumerate_missing "" + } >&2 + exit 1 + fi + echo "check-test-parity: ok - all $total_manifest parity tests present ($COUNT total methods, floor $FLOOR)." + exit 0 + ;; + * ) + echo "check-test-parity: unknown argument '$1' (use --ratchet | --list | --bump | no-arg)" >&2 + exit 2 + ;; +esac diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj b/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj new file mode 100644 index 00000000..75ca117b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj @@ -0,0 +1,22 @@ + + + + Microsoft.AgentHostProtocol + net8.0;net9.0 + Microsoft.AgentHostProtocol.Abstractions + true + Microsoft.AgentHostProtocol.Abstractions + + Wire types, reducers' data contracts, and transport/serializer + interfaces for the Agent Host Protocol (AHP) — the synchronized, + multi-client state protocol for AI agent sessions. This package has no + I/O and no dependencies beyond the base class library; reference it to + parse, construct, or inspect AHP messages, or to implement a transport. + + + + + + + + diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs new file mode 100644 index 00000000..d32c1404 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -0,0 +1,1996 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +// ─── ActionType ────────────────────────────────────────────────────── + +/// +/// Discriminant values for all state actions. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ActionType +{ + [WireValue("root/agentsChanged")] + RootAgentsChanged, + [WireValue("root/activeSessionsChanged")] + RootActiveSessionsChanged, + [WireValue("session/ready")] + SessionReady, + [WireValue("session/creationFailed")] + SessionCreationFailed, + [WireValue("session/turnStarted")] + SessionTurnStarted, + [WireValue("session/delta")] + SessionDelta, + [WireValue("session/responsePart")] + SessionResponsePart, + [WireValue("session/toolCallStart")] + SessionToolCallStart, + [WireValue("session/toolCallDelta")] + SessionToolCallDelta, + [WireValue("session/toolCallReady")] + SessionToolCallReady, + [WireValue("session/toolCallConfirmed")] + SessionToolCallConfirmed, + [WireValue("session/toolCallComplete")] + SessionToolCallComplete, + [WireValue("session/toolCallResultConfirmed")] + SessionToolCallResultConfirmed, + [WireValue("session/toolCallContentChanged")] + SessionToolCallContentChanged, + [WireValue("session/turnComplete")] + SessionTurnComplete, + [WireValue("session/turnCancelled")] + SessionTurnCancelled, + [WireValue("session/error")] + SessionError, + [WireValue("session/titleChanged")] + SessionTitleChanged, + [WireValue("session/usage")] + SessionUsage, + [WireValue("session/reasoning")] + SessionReasoning, + [WireValue("session/modelChanged")] + SessionModelChanged, + [WireValue("session/agentChanged")] + SessionAgentChanged, + [WireValue("session/serverToolsChanged")] + SessionServerToolsChanged, + [WireValue("session/activeClientChanged")] + SessionActiveClientChanged, + [WireValue("session/activeClientToolsChanged")] + SessionActiveClientToolsChanged, + [WireValue("session/pendingMessageSet")] + SessionPendingMessageSet, + [WireValue("session/pendingMessageRemoved")] + SessionPendingMessageRemoved, + [WireValue("session/queuedMessagesReordered")] + SessionQueuedMessagesReordered, + [WireValue("session/inputRequested")] + SessionInputRequested, + [WireValue("session/inputAnswerChanged")] + SessionInputAnswerChanged, + [WireValue("session/inputCompleted")] + SessionInputCompleted, + [WireValue("session/customizationsChanged")] + SessionCustomizationsChanged, + [WireValue("session/customizationToggled")] + SessionCustomizationToggled, + [WireValue("session/customizationUpdated")] + SessionCustomizationUpdated, + [WireValue("session/customizationRemoved")] + SessionCustomizationRemoved, + [WireValue("session/mcpServerStateChanged")] + SessionMcpServerStateChanged, + [WireValue("session/truncated")] + SessionTruncated, + [WireValue("session/isReadChanged")] + SessionIsReadChanged, + [WireValue("session/isArchivedChanged")] + SessionIsArchivedChanged, + [WireValue("session/activityChanged")] + SessionActivityChanged, + [WireValue("session/changesetsChanged")] + SessionChangesetsChanged, + [WireValue("session/configChanged")] + SessionConfigChanged, + [WireValue("session/metaChanged")] + SessionMetaChanged, + [WireValue("changeset/statusChanged")] + ChangesetStatusChanged, + [WireValue("changeset/fileSet")] + ChangesetFileSet, + [WireValue("changeset/fileRemoved")] + ChangesetFileRemoved, + [WireValue("changeset/operationsChanged")] + ChangesetOperationsChanged, + [WireValue("changeset/operationStatusChanged")] + ChangesetOperationStatusChanged, + [WireValue("changeset/cleared")] + ChangesetCleared, + [WireValue("annotations/set")] + AnnotationsSet, + [WireValue("annotations/removed")] + AnnotationsRemoved, + [WireValue("annotations/entrySet")] + AnnotationsEntrySet, + [WireValue("annotations/entryRemoved")] + AnnotationsEntryRemoved, + [WireValue("root/terminalsChanged")] + RootTerminalsChanged, + [WireValue("root/configChanged")] + RootConfigChanged, + [WireValue("terminal/data")] + TerminalData, + [WireValue("terminal/input")] + TerminalInput, + [WireValue("terminal/resized")] + TerminalResized, + [WireValue("terminal/claimed")] + TerminalClaimed, + [WireValue("terminal/titleChanged")] + TerminalTitleChanged, + [WireValue("terminal/cwdChanged")] + TerminalCwdChanged, + [WireValue("terminal/exited")] + TerminalExited, + [WireValue("terminal/cleared")] + TerminalCleared, + [WireValue("terminal/commandDetectionAvailable")] + TerminalCommandDetectionAvailable, + [WireValue("terminal/commandExecuted")] + TerminalCommandExecuted, + [WireValue("terminal/commandFinished")] + TerminalCommandFinished, + [WireValue("resourceWatch/changed")] + ResourceWatchChanged, +} + +// ─── Action Envelope ───────────────────────────────────────────────── + +/// +/// Identifies the client that originally dispatched an action. +/// +public sealed class ActionOrigin +{ + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; + + [JsonPropertyName("clientSeq")] + public long ClientSeq { get; set; } +} + +/// +/// ActionEnvelope wraps every action with the channel URI it belongs to, +/// the server-assigned monotonic sequence number, and an optional origin. +/// +public sealed class ActionEnvelope +{ + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + [JsonPropertyName("action")] + public StateAction Action { get; set; } = new(); + + [JsonPropertyName("serverSeq")] + public long ServerSeq { get; set; } + + [JsonPropertyName("origin")] + public ActionOrigin? Origin { get; set; } + + [JsonPropertyName("rejectionReason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RejectionReason { get; set; } +} + +// ─── Action Payloads ───────────────────────────────────────────────── + +/// +/// Fired when available agent backends or their models change. +/// +public sealed class RootAgentsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated agent list + /// + [JsonPropertyName("agents")] + public List Agents { get; set; } = null!; +} + +/// +/// Fired when the number of active sessions changes. +/// +public sealed class RootActiveSessionsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Current count of active sessions + /// + [JsonPropertyName("activeSessions")] + public long ActiveSessions { get; set; } +} + +/// +/// Fired when agent-host configuration values change. +/// +/// By default, the reducer merges the new values into `state.config.values`. +/// Set `replace` to `true` to replace all values instead of merging. +/// +public sealed class RootConfigChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated config values + /// + [JsonPropertyName("config")] + public Dictionary Config { get; set; } = null!; + + /// + /// When `true`, replaces all config values instead of merging + /// + [JsonPropertyName("replace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Replace { get; set; } +} + +/// +/// Session backend initialized successfully. +/// +public sealed class SessionReadyAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } +} + +/// +/// Session backend failed to initialize. +/// +public sealed class SessionCreationFailedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Error details + /// + [JsonPropertyName("error")] + public required ErrorInfo Error { get; set; } +} + +/// +/// A new message has been sent to the agent, and a new turn starts. +/// +/// A client is only allowed to send {@link MessageKind.User} messages. +/// +public sealed class SessionTurnStartedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// The new message + /// + [JsonPropertyName("message")] + public required Message Message { get; set; } + + /// + /// If this turn was auto-started from a queued message, the ID of that message + /// + [JsonPropertyName("queuedMessageId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QueuedMessageId { get; set; } +} + +/// +/// Streaming text chunk from the assistant, appended to a specific response part. +/// +/// The server MUST first emit a `session/responsePart` to create the target +/// part (markdown or reasoning), then use this action to append text to it. +/// +public sealed class SessionDeltaAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Identifier of the response part to append to + /// + [JsonPropertyName("partId")] + public string PartId { get; set; } = ""; + + /// + /// Text chunk + /// + [JsonPropertyName("content")] + public string Content { get; set; } = ""; +} + +/// +/// Structured content appended to the response. +/// +public sealed class SessionResponsePartAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Response part (markdown or content ref) + /// + [JsonPropertyName("part")] + public required ResponsePart Part { get; set; } +} + +/// +/// A tool call begins — parameters are streaming from the LM. +/// +/// The server sets {@link ToolCallContributor | `contributor`} to identify +/// the origin of the tool. For client-provided tools, the named client is +/// responsible for executing the tool once it reaches the `running` state +/// and dispatching `session/toolCallComplete`. For MCP-served tools, the +/// server executes the call against the named `McpServerCustomization`. +/// +public sealed class SessionToolCallStartAction +{ + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `ptyTerminal` key with `{ input: string; output: string }` + /// indicates the tool operated on a terminal (both `input` and `output` may + /// contain escape sequences). + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. Absent for + /// server-side tools that are not contributed by a client or MCP server. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } +} + +/// +/// Streaming partial parameters for a tool call. +/// +public sealed class SessionToolCallDeltaAction +{ + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `ptyTerminal` key with `{ input: string; output: string }` + /// indicates the tool operated on a terminal (both `input` and `output` may + /// contain escape sequences). + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Partial parameter content to append + /// + [JsonPropertyName("content")] + public string Content { get; set; } = ""; + + /// + /// Updated progress message + /// + [JsonPropertyName("invocationMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? InvocationMessage { get; set; } +} + +/// +/// Tool call parameters are complete, or a running tool requires re-confirmation. +/// +/// When dispatched for a `streaming` tool call, transitions to `pending-confirmation` +/// or directly to `running` if `confirmed` is set. +/// +/// When dispatched for a `running` tool call (e.g. mid-execution permission needed), +/// transitions back to `pending-confirmation`. The `invocationMessage` and `_meta` +/// SHOULD be updated to describe the specific confirmation needed. Clients use the +/// standard `session/toolCallConfirmed` flow to approve or deny. +/// +/// For client-provided tools, the server typically sets `confirmed` to +/// `'not-needed'` so the tool transitions directly to `running`, where the +/// owning client can begin execution immediately. +/// +public sealed class SessionToolCallReadyAction +{ + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `ptyTerminal` key with `{ input: string; output: string }` + /// indicates the tool operated on a terminal (both `input` and `output` may + /// contain escape sequences). + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Message describing what the tool will do or what confirmation is needed + /// + [JsonPropertyName("invocationMessage")] + public StringOrMarkdown InvocationMessage { get; set; } = new(); + + /// + /// Raw tool input + /// + [JsonPropertyName("toolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + /// + /// Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) + /// + [JsonPropertyName("confirmationTitle")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ConfirmationTitle { get; set; } + + /// + /// File edits that this tool call will perform, for preview before confirmation + /// + [JsonPropertyName("edits")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Edits { get; set; } + + /// + /// Whether the agent host allows the client to edit the tool's input parameters before confirming + /// + [JsonPropertyName("editable")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Editable { get; set; } + + /// + /// If set, the tool was auto-confirmed and transitions directly to `running` + /// + [JsonPropertyName("confirmed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; set; } + + /// + /// Options the server offers for this confirmation. When present, the client + /// SHOULD render these instead of a plain approve/deny UI. Each option + /// belongs to a {@link ConfirmationOptionGroup} so the client can still + /// categorise the choices. + /// + [JsonPropertyName("options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; set; } +} + +/// +/// SessionToolCallConfirmedAction — the client approves or denies a +/// pending tool call (merged approved + denied variants on the wire). +/// +public sealed class SessionToolCallConfirmedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("approved")] + public bool Approved { get; set; } + + [JsonPropertyName("confirmed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; set; } + + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallCancellationReason? Reason { get; set; } + + [JsonPropertyName("editedToolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditedToolInput { get; set; } + + [JsonPropertyName("userSuggestion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; set; } + + [JsonPropertyName("reasonMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; set; } + + [JsonPropertyName("selectedOptionId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SelectedOptionId { get; set; } +} + +/// +/// Tool execution finished. Transitions to `completed` or `pending-result-confirmation` +/// if `requiresResultConfirmation` is `true`. +/// +/// For client-provided tools (where `toolClientId` is set on the tool call state), +/// the owning client dispatches this action with the execution result. The server +/// SHOULD reject this action if the dispatching client does not match `toolClientId`. +/// +/// Servers waiting on a client tool call MAY time out after a reasonable duration +/// if the implementing client disconnects or becomes unresponsive, and dispatch +/// this action with `result.success = false` and an appropriate error. +/// +public sealed class SessionToolCallCompleteAction +{ + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `ptyTerminal` key with `{ input: string; output: string }` + /// indicates the tool operated on a terminal (both `input` and `output` may + /// contain escape sequences). + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Execution result + /// + [JsonPropertyName("result")] + public required ToolCallResult Result { get; set; } + + /// + /// If true, the result requires client approval before finalizing + /// + [JsonPropertyName("requiresResultConfirmation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RequiresResultConfirmation { get; set; } +} + +/// +/// Client approves or denies a tool's result. +/// +/// If `approved` is `false`, the tool transitions to `cancelled` with reason `result-denied`. +/// +public sealed class SessionToolCallResultConfirmedAction +{ + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `ptyTerminal` key with `{ input: string; output: string }` + /// indicates the tool operated on a terminal (both `input` and `output` may + /// contain escape sequences). + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Whether the result was approved + /// + [JsonPropertyName("approved")] + public bool Approved { get; set; } +} + +/// +/// Turn finished — the assistant is idle. +/// +public sealed class SessionTurnCompleteAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; +} + +/// +/// Turn was aborted; server stops processing. +/// +public sealed class SessionTurnCancelledAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; +} + +/// +/// Error during turn processing. +/// +public sealed class SessionErrorAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Error details + /// + [JsonPropertyName("error")] + public required ErrorInfo Error { get; set; } +} + +/// +/// Session title updated. Fired by the server when the title is auto-generated +/// from conversation, or dispatched by a client to rename a session. +/// +public sealed class SessionTitleChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New title + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; +} + +/// +/// Token usage report for a turn. +/// +public sealed class SessionUsageAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Token usage data + /// + [JsonPropertyName("usage")] + public required UsageInfo Usage { get; set; } +} + +/// +/// Reasoning/thinking text from the model, appended to a specific reasoning response part. +/// +/// The server MUST first emit a `session/responsePart` to create the target +/// reasoning part, then use this action to append text to it. +/// +public sealed class SessionReasoningAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Identifier of the reasoning response part to append to + /// + [JsonPropertyName("partId")] + public string PartId { get; set; } = ""; + + /// + /// Reasoning text chunk + /// + [JsonPropertyName("content")] + public string Content { get; set; } = ""; +} + +/// +/// Model changed for this session. +/// +public sealed class SessionModelChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New model selection + /// + [JsonPropertyName("model")] + public required ModelSelection Model { get; set; } +} + +/// +/// Custom agent selection changed for this session. +/// +/// Omitting `agent` (or setting it to `undefined`) clears the selection and +/// resets the session to no selected custom agent (provider default behavior). +/// +/// When a turn is currently active, the server MUST defer the change until +/// the active turn completes, then apply it for the next turn (same rule as +/// {@link SessionModelChangedAction | `session/modelChanged`}). +/// +public sealed class SessionAgentChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New agent selection, or `undefined` to clear the selection and reset the + /// session to no selected custom agent. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } +} + +/// +/// The read state of the session changed. +/// +/// Dispatched by a client to mark a session as read (e.g. after viewing it) +/// or unread (e.g. after new activity since the client last looked at it). +/// +public sealed class SessionIsReadChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Whether the session has been read + /// + [JsonPropertyName("isRead")] + public bool IsRead { get; set; } +} + +/// +/// The archived state of the session changed. +/// +/// Dispatched by a client to archive a session (e.g. the task is +/// complete) or to unarchive it. +/// +public sealed class SessionIsArchivedChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Whether the session is archived + /// + [JsonPropertyName("isArchived")] + public bool IsArchived { get; set; } +} + +/// +/// The activity description of the session changed. +/// +/// Dispatched by the server to indicate what the session is currently doing +/// (e.g. running a tool, thinking). Clear activity by setting it to `undefined`. +/// +public sealed class SessionActivityChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Human-readable description of current activity, or `undefined` to clear + /// + [JsonPropertyName("activity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; set; } +} + +/// +/// The {@link Changeset | catalogue of changesets} the agent host +/// advertises for this session changed. Replaces +/// {@link SessionState.changesets | `state.changesets`} entirely +/// (full-replacement semantics) — set to `undefined` to clear the +/// catalogue. +/// +/// Producers dispatch this whenever entries are added or removed. The +/// fan-out happens through this action so observers see catalogue +/// mutations in the same {@link ChangesetAction | per-changeset} action +/// stream they already follow for file-level updates. +/// +public sealed class SessionChangesetsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New catalogue, or `undefined` to clear it + /// + [JsonPropertyName("changesets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Changesets { get; set; } +} + +/// +/// Server tools for this session have changed. +/// +/// Full-replacement semantics: the `tools` array replaces the previous `serverTools` entirely. +/// +public sealed class SessionServerToolsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated server tools list (full replacement) + /// + [JsonPropertyName("tools")] + public List Tools { get; set; } = null!; +} + +/// +/// The active client for this session has changed. +/// +/// A client dispatches this action with its own `SessionActiveClient` to claim +/// the active role, or with `null` to release it. The server SHOULD reject if +/// another client is already active. The server SHOULD automatically dispatch +/// this action with `activeClient: null` when the active client disconnects. +/// +public sealed class SessionActiveClientChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The new active client, or `null` to unset + /// + [JsonPropertyName("activeClient")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionActiveClient? ActiveClient { get; set; } +} + +/// +/// The active client's tool list has changed. +/// +/// Full-replacement semantics: the `tools` array replaces the active client's +/// previous tools entirely. The server SHOULD reject if the dispatching client +/// is not the current active client. +/// +public sealed class SessionActiveClientToolsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated client tools list (full replacement) + /// + [JsonPropertyName("tools")] + public List Tools { get; set; } = null!; +} + +/// +/// A pending message was set (upsert semantics: creates or replaces). +/// +/// For steering messages, this always replaces the single steering message. +/// For queued messages, if a message with the given `id` already exists it is +/// updated in place; otherwise it is appended to the queue. If the session is +/// idle when a queued message is set, the server SHOULD immediately consume it +/// and start a new turn. +/// +/// A client is only allowed to send {@link MessageKind.User} messages. +/// +public sealed class SessionPendingMessageSetAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Whether this is a steering or queued message + /// + [JsonPropertyName("kind")] + public PendingMessageKind Kind { get; set; } + + /// + /// Unique identifier for this pending message + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// The message content + /// + [JsonPropertyName("message")] + public required Message Message { get; set; } +} + +/// +/// A pending message was removed (steering or queued). +/// +/// Dispatched by clients to cancel a pending message, or by the server when +/// it consumes a message (e.g. starting a turn from a queued message or +/// injecting a steering message into the current turn). +/// +public sealed class SessionPendingMessageRemovedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Whether this is a steering or queued message + /// + [JsonPropertyName("kind")] + public PendingMessageKind Kind { get; set; } + + /// + /// Identifier of the pending message to remove + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; +} + +/// +/// Reorder the queued messages. +/// +/// The `order` array contains the IDs of queued messages in their new +/// desired order. IDs not present in the current queue are ignored. +/// Queued messages whose IDs are absent from `order` are appended at +/// the end in their original relative order (so a client with a stale +/// view of the queue never silently drops messages). +/// +public sealed class SessionQueuedMessagesReorderedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Queued message IDs in the desired order + /// + [JsonPropertyName("order")] + public List Order { get; set; } = null!; +} + +/// +/// A session requested input from the user. +/// +/// Full-request upsert semantics: the `request` replaces any existing request +/// with the same `id`, or is appended if it is new. Answer drafts are preserved +/// unless `request.answers` is provided. +/// +public sealed class SessionInputRequestedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Input request to create or replace + /// + [JsonPropertyName("request")] + public required SessionInputRequest Request { get; set; } +} + +/// +/// A client updated, submitted, skipped, or removed a single in-progress answer. +/// +/// Dispatching with `answer: undefined` removes that question's answer draft. +/// +public sealed class SessionInputAnswerChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Input request identifier + /// + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + /// + /// Question identifier within the input request + /// + [JsonPropertyName("questionId")] + public string QuestionId { get; set; } = ""; + + /// + /// Updated answer, or `undefined` to clear an answer draft + /// + [JsonPropertyName("answer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionInputAnswer? Answer { get; set; } +} + +/// +/// A client accepted, declined, or cancelled a session input request. +/// +/// If accepted, the server uses `answers` (when provided) plus the request's +/// synced answer state to resume the blocked operation. +/// +public sealed class SessionInputCompletedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Input request identifier + /// + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + /// + /// Completion outcome + /// + [JsonPropertyName("response")] + public SessionInputResponseKind Response { get; set; } + + /// + /// Optional final answer replacement, keyed by question ID + /// + [JsonPropertyName("answers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; set; } +} + +/// +/// The session's customizations have changed. +/// +/// Full-replacement semantics: the `customizations` array replaces the +/// previous `customizations` entirely. +/// +public sealed class SessionCustomizationsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated customization list (full replacement). + /// + [JsonPropertyName("customizations")] + public List Customizations { get; set; } = null!; +} + +/// +/// A client toggled a container customization on or off. +/// +/// Targets a top-level container (plugin or directory) by `id`. Only +/// containers have an `enabled` flag; children are always active when +/// their container is enabled. Is a no-op when no matching container is +/// found. +/// +public sealed class SessionCustomizationToggledAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The id of the container to toggle. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Whether to enable or disable the container. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } +} + +/// +/// Upserts a top-level customization (plugin or directory). +/// +/// The reducer locates the existing entry by `customization.id`: +/// +/// - If found, the entry is replaced entirely with `customization`, +/// including its `children` array. To preserve existing children, the +/// host must include them on the payload. +/// - If not found, the entry is appended. +/// +public sealed class SessionCustomizationUpdatedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The customization to upsert (matched by `customization.id`). + /// + [JsonPropertyName("customization")] + public required Customization Customization { get; set; } +} + +/// +/// Removes a customization by id. +/// +/// Searches every container and its children for the entry. If the entry +/// is a container, its children are removed with it. Is a no-op when no +/// matching id is found. +/// +public sealed class SessionCustomizationRemovedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The id of the customization to remove. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; +} + +/// +/// Updates the runtime fields of an existing +/// {@link McpServerCustomization} — narrow alternative to +/// {@link SessionCustomizationUpdatedAction} for the high-frequency +/// `starting` ↔ `ready` ↔ `authRequired` transitions. +/// +/// Locates the target entry by `id`, searching both the top-level +/// customization list and the `children` array of every container. +/// Replaces the entry's {@link McpServerCustomization.state | `state`} +/// and {@link McpServerCustomization.channel | `channel`} +/// (full-replacement semantics: omit `channel` to clear an existing +/// channel URI). Other fields of the customization are preserved. +/// +/// Is a no-op when no matching `McpServerCustomization` is found. To +/// update any other field (name, icons, `mcpApp` capabilities, etc.) use +/// {@link SessionCustomizationUpdatedAction} instead. +/// +/// When the transition is to {@link McpServerStatus.AuthRequired} +/// because of a request issued mid-turn, the host SHOULD also raise +/// {@link SessionStatus.InputNeeded} on the session — see +/// {@link McpServerAuthRequiredState} for the rationale. +/// +public sealed class SessionMcpServerStateChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The id of the {@link McpServerCustomization} to update. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// The new lifecycle state. + /// + [JsonPropertyName("state")] + public required McpServerState State { get; set; } + + /// + /// Updated `mcp://` side-channel URI. Full-replacement: omit to clear + /// an existing channel (typical when leaving + /// {@link McpServerStatus.Ready | `Ready`}). + /// + [JsonPropertyName("channel")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Channel { get; set; } +} + +/// +/// Truncates a session's history. If `turnId` is provided, all turns after that +/// turn are removed and the specified turn is kept. If `turnId` is omitted, all +/// turns are removed. +/// +/// If there is an active turn it is silently dropped and the session status +/// returns to `idle`. +/// +/// Common use-case: truncate old data then dispatch a new +/// `session/turnStarted` with an edited message. +/// +public sealed class SessionTruncatedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Keep turns up to and including this turn. Omit to clear all turns. + /// + [JsonPropertyName("turnId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; set; } +} + +/// +/// Client changed a mutable config value mid-session. +/// +/// Only properties with `sessionMutable: true` in the config schema may be +/// changed. The server validates and broadcasts the action; the reducer merges +/// the new values into `state.config.values`. +/// +public sealed class SessionConfigChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated config values + /// + [JsonPropertyName("config")] + public Dictionary Config { get; set; } = null!; + + /// + /// When `true`, replaces all config values instead of merging + /// + [JsonPropertyName("replace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Replace { get; set; } +} + +/// +/// The session's `_meta` side-channel changed. Replaces `state._meta` +/// entirely (full-replacement semantics). Producers SHOULD merge any +/// keys they wish to preserve into the new value before dispatching. +/// +public sealed class SessionMetaChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New `_meta` payload, or `undefined` to clear it + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// Partial content produced while a tool is still executing. +/// +/// Replaces the `content` array on the running tool call state. Clients can +/// use this to display live feedback (e.g. a terminal reference) before the +/// tool completes. +/// +/// For client-provided tools (where `toolClientId` is set on the tool call state), +/// the owning client dispatches this action to stream intermediate content while +/// executing. The server SHOULD reject this action if the dispatching client does +/// not match `toolClientId`. +/// +public sealed class SessionToolCallContentChangedAction +{ + /// + /// Turn identifier + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// Tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `ptyTerminal` key with `{ input: string; output: string }` + /// indicates the tool operated on a terminal (both `input` and `output` may + /// contain escape sequences). + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The current partial content for the running tool call + /// + [JsonPropertyName("content")] + public List Content { get; set; } = null!; +} + +/// +/// The {@link ChangesetState.status} for this changeset transitioned (e.g. +/// `computing → ready`). The error payload is set together with `status` +/// whenever it transitions to {@link ChangesetStatus.Error | Error}. +/// +public sealed class ChangesetStatusChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New computation lifecycle status. + /// + [JsonPropertyName("status")] + public ChangesetStatus Status { get; set; } + + /// + /// Cause when `status === ChangesetStatus.Error`; otherwise omitted. + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; set; } +} + +/// +/// Upsert a {@link ChangesetFile} in the changeset — adds a new entry, or +/// replaces an existing one identified by {@link ChangesetFile.id}. +/// +public sealed class ChangesetFileSetAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The new or replacement file entry. + /// + [JsonPropertyName("file")] + public required ChangesetFile File { get; set; } +} + +/// +/// Remove a {@link ChangesetFile} from the changeset by its id. +/// +/// Typically dispatched when a file is reverted, staged out, or otherwise +/// no longer in scope (e.g. a renamed file is replaced by a new entry). +/// +public sealed class ChangesetFileRemovedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The {@link ChangesetFile.id} of the file to remove. + /// + [JsonPropertyName("fileId")] + public string FileId { get; set; } = ""; +} + +/// +/// The set of operations available on this changeset changed. Full +/// replacement semantics: `operations` replaces the previous list (or +/// removes it entirely when `operations` is `undefined`). +/// +public sealed class ChangesetOperationsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated operation list. Pass `undefined` to clear all operations. + /// + [JsonPropertyName("operations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Operations { get; set; } +} + +/// +/// The {@link ChangesetOperation.status} for a single operation transitioned +/// (e.g. `idle → running → idle`, or `running → error`). The error payload +/// is set together with `status` whenever it transitions to +/// {@link ChangesetOperationStatus.Error | Error}, and cleared on any other +/// transition. +/// +/// Targets one operation by its {@link ChangesetOperation.id}. If no +/// operation with that id is currently present in the changeset, the action +/// is a no-op. Use {@link ChangesetOperationsChangedAction} to add, remove, +/// or otherwise replace the operation list itself. +/// +public sealed class ChangesetOperationStatusChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The {@link ChangesetOperation.id} whose status changed. + /// + [JsonPropertyName("operationId")] + public string OperationId { get; set; } = ""; + + /// + /// New execution status. + /// + [JsonPropertyName("status")] + public ChangesetOperationStatus Status { get; set; } + + /// + /// Cause when `status === ChangesetOperationStatus.Error`; otherwise omitted. + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; set; } +} + +/// +/// Drop every file from the changeset. +/// +/// Two cases use this: +/// 1. The underlying source moved (branch switched, fork point invalidated, +/// …) and the server is recomputing from scratch — subsequent +/// {@link ChangesetFileSetAction} entries will repopulate it. +/// 2. The owning session has ended and the URI is becoming +/// un-subscribable — the server will unsubscribe all clients shortly +/// after dispatching this action. +/// +/// Clients SHOULD release any references on receipt and SHOULD NOT +/// distinguish the two cases from the action alone — instead, react to +/// the corresponding session-level lifecycle signal (e.g. +/// `root/sessionRemoved`) for the "going away" case. +/// +public sealed class ChangesetClearedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } +} + +/// +/// Fired when the list of known terminals changes. +/// +/// Full-replacement semantics: the `terminals` array replaces the previous +/// `terminals` entirely. +/// +public sealed class RootTerminalsChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Updated terminal list (full replacement) + /// + [JsonPropertyName("terminals")] + public List Terminals { get; set; } = null!; +} + +/// +/// Terminal output data (pty → client direction). +/// +/// Appends `data` to the terminal's `content` in the reducer. +/// +/// `terminal/data` and `terminal/input` are intentionally separate actions +/// because standard write-ahead reconciliation is not safe for terminal I/O. +/// A pty is a stateful, mutable process — optimistically applying input or +/// predicting output would produce incorrect state. Instead, `terminal/input` +/// is a side-effect-only action (client → server → pty), and `terminal/data` +/// is server-authoritative output (pty → server → client). +/// +public sealed class TerminalDataAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Output data (may contain ANSI escape sequences) + /// + [JsonPropertyName("data")] + public string Data { get; set; } = ""; +} + +/// +/// Keyboard input sent to the terminal process (client → pty direction). +/// +/// This is a side-effect-only action: the server forwards the data to the +/// terminal's pty. The reducer treats this as a no-op since `terminal/data` +/// actions will reflect any resulting output. +/// +/// See `terminal/data` for why these two actions are kept separate. +/// +public sealed class TerminalInputAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Input data to send to the pty + /// + [JsonPropertyName("data")] + public string Data { get; set; } = ""; +} + +/// +/// Terminal dimensions changed. +/// +/// Dispatchable by clients to request a resize, or by the server to inform +/// clients of the actual terminal dimensions. +/// +public sealed class TerminalResizedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Terminal width in columns + /// + [JsonPropertyName("cols")] + public long Cols { get; set; } + + /// + /// Terminal height in rows + /// + [JsonPropertyName("rows")] + public long Rows { get; set; } +} + +/// +/// Terminal claim changed. A client or session transfers ownership of the terminal. +/// +/// The server SHOULD reject if the dispatching client does not currently hold +/// the claim. +/// +public sealed class TerminalClaimedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The new claim + /// + [JsonPropertyName("claim")] + public required TerminalClaim Claim { get; set; } +} + +/// +/// Terminal title changed. +/// +/// Fired by the server when the terminal process updates its title (e.g. via +/// escape sequences), or dispatched by a client to rename a terminal. +/// +public sealed class TerminalTitleChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New terminal title + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; +} + +/// +/// Terminal working directory changed. +/// +public sealed class TerminalCwdChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// New working directory + /// + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = ""; +} + +/// +/// Terminal process exited. +/// +public sealed class TerminalExitedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Process exit code. `undefined` if the process was killed without an exit code. + /// + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } +} + +/// +/// Terminal scrollback buffer cleared. +/// +public sealed class TerminalClearedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } +} + +/// +/// Shell integration has loaded and the terminal now supports command +/// detection. The server dispatches this when shell integration becomes +/// available (which may happen asynchronously after the terminal is created). +/// +/// Clients MUST NOT assume command detection is available until this action +/// (or `terminal/commandExecuted`) has been received. +/// +public sealed class TerminalCommandDetectionAvailableAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } +} + +/// +/// A command has been submitted to the shell and is now executing. +/// All subsequent `terminal/data` actions (until the matching +/// `terminal/commandFinished`) constitute this command's output. +/// +public sealed class TerminalCommandExecutedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Stable identifier for this command, scoped to the terminal URI. + /// Allows correlating `commandExecuted` → `commandFinished` pairs. + /// + [JsonPropertyName("commandId")] + public string CommandId { get; set; } = ""; + + /// + /// The command line text that was submitted + /// + [JsonPropertyName("commandLine")] + public string CommandLine { get; set; } = ""; + + /// + /// Unix timestamp (ms) of when the command started executing, as measured + /// on the server. + /// + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } +} + +/// +/// A command has finished executing. +/// +/// The sequence of `terminal/data` actions between the preceding +/// `terminal/commandExecuted` (same `commandId`) and this action constitutes +/// the complete output of the command. +/// +public sealed class TerminalCommandFinishedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// Matches the `commandId` from the corresponding `commandExecuted` + /// + [JsonPropertyName("commandId")] + public string CommandId { get; set; } = ""; + + /// + /// Shell exit code. `undefined` if the shell did not report one. + /// + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } + + /// + /// Wall-clock duration of the command in milliseconds, as measured by the + /// shell integration script on the server side. + /// + [JsonPropertyName("durationMs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? DurationMs { get; set; } +} + +/// +/// A batch of resource changes observed by the watcher. +/// +/// Watch events are coalesced into batches by the server to keep the +/// action stream tractable; an empty `changes.items` list MUST NOT be +/// dispatched. The reducer does not retain change history — these +/// actions exist purely to deliver events to subscribers, who consume +/// them directly off the action stream and apply their own logic. +/// +public sealed class ResourceWatchChangedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The set of changes in this batch, wrapped for forward compatibility. + /// + [JsonPropertyName("changes")] + public JsonElement Changes { get; set; } +} + +/// +/// Upsert an {@link Annotation} in the annotations channel — adds a new +/// annotation, or replaces an existing one identified by +/// {@link Annotation.id}. +/// +/// Dispatched by a client to create an annotation (together with its +/// mandatory first entry) or to re-anchor / resolve an existing one; the +/// dispatching client assigns the {@link Annotation.id} and the id of any +/// new entry. When replacing, the full annotation payload (including its +/// {@link Annotation.entries | entries} list) is substituted; producers +/// SHOULD prefer {@link AnnotationsEntrySetAction} for per-entry edits to +/// keep wire updates small. +/// +public sealed class AnnotationsSetAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The new or replacement annotation. MUST contain at least one entry. + /// + [JsonPropertyName("annotation")] + public required Annotation Annotation { get; set; } +} + +/// +/// Remove an {@link Annotation} from the channel by its id. +/// +/// Dispatched to delete an entire annotation and every entry it contains. +/// Because the protocol forbids empty annotations, a client that wants to +/// remove the last remaining entry dispatches this action — collapsing the +/// annotation — rather than {@link AnnotationsEntryRemovedAction}. +/// +public sealed class AnnotationsRemovedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The {@link Annotation.id} of the annotation to remove. + /// + [JsonPropertyName("annotationId")] + public string AnnotationId { get; set; } = ""; +} + +/// +/// Upsert an {@link AnnotationEntry} within an existing annotation — adds a +/// new entry, or replaces one identified by {@link AnnotationEntry.id}. The +/// dispatching client assigns the {@link AnnotationEntry.id} of a new entry. +/// If {@link annotationId} does not match any current annotation the action +/// is a no-op. +/// +public sealed class AnnotationsEntrySetAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The {@link Annotation.id} the entry belongs to. + /// + [JsonPropertyName("annotationId")] + public string AnnotationId { get; set; } = ""; + + /// + /// The new or replacement entry. + /// + [JsonPropertyName("entry")] + public required AnnotationEntry Entry { get; set; } +} + +/// +/// Remove a single {@link AnnotationEntry} from an annotation without +/// collapsing the annotation itself. Used when more than one entry remains — +/// to remove the last entry a client dispatches {@link AnnotationsRemovedAction} +/// instead, since the protocol forbids empty annotations. +/// +/// If either {@link annotationId} or {@link entryId} does not match the +/// current state the action is a no-op. +/// +public sealed class AnnotationsEntryRemovedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + /// + /// The {@link Annotation.id} the entry belongs to. + /// + [JsonPropertyName("annotationId")] + public string AnnotationId { get; set; } = ""; + + /// + /// The {@link AnnotationEntry.id} to remove. + /// + [JsonPropertyName("entryId")] + public string EntryId { get; set; } = ""; +} + +// ─── StateAction Union ─────────────────────────────────────────────── + +/// +/// StateAction is the discriminated union of every state action. +/// +[JsonConverter(typeof(StateActionConverter))] +public sealed class StateAction : AhpUnion +{ + /// Creates an empty StateAction (no active variant). + public StateAction() { } + + /// Creates a StateAction wrapping the given variant value. + public StateAction(object? value) : base(value) { } +} + +/// System.Text.Json converter for the StateAction discriminated union. +internal sealed class StateActionConverter : UnionConverter +{ + public StateActionConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["root/agentsChanged"] = typeof(RootAgentsChangedAction), + ["root/activeSessionsChanged"] = typeof(RootActiveSessionsChangedAction), + ["root/configChanged"] = typeof(RootConfigChangedAction), + ["session/ready"] = typeof(SessionReadyAction), + ["session/creationFailed"] = typeof(SessionCreationFailedAction), + ["session/turnStarted"] = typeof(SessionTurnStartedAction), + ["session/delta"] = typeof(SessionDeltaAction), + ["session/responsePart"] = typeof(SessionResponsePartAction), + ["session/toolCallStart"] = typeof(SessionToolCallStartAction), + ["session/toolCallDelta"] = typeof(SessionToolCallDeltaAction), + ["session/toolCallReady"] = typeof(SessionToolCallReadyAction), + ["session/toolCallConfirmed"] = typeof(SessionToolCallConfirmedAction), + ["session/toolCallComplete"] = typeof(SessionToolCallCompleteAction), + ["session/toolCallResultConfirmed"] = typeof(SessionToolCallResultConfirmedAction), + ["session/turnComplete"] = typeof(SessionTurnCompleteAction), + ["session/turnCancelled"] = typeof(SessionTurnCancelledAction), + ["session/error"] = typeof(SessionErrorAction), + ["session/titleChanged"] = typeof(SessionTitleChangedAction), + ["session/usage"] = typeof(SessionUsageAction), + ["session/reasoning"] = typeof(SessionReasoningAction), + ["session/modelChanged"] = typeof(SessionModelChangedAction), + ["session/agentChanged"] = typeof(SessionAgentChangedAction), + ["session/isReadChanged"] = typeof(SessionIsReadChangedAction), + ["session/isArchivedChanged"] = typeof(SessionIsArchivedChangedAction), + ["session/activityChanged"] = typeof(SessionActivityChangedAction), + ["session/changesetsChanged"] = typeof(SessionChangesetsChangedAction), + ["session/serverToolsChanged"] = typeof(SessionServerToolsChangedAction), + ["session/activeClientChanged"] = typeof(SessionActiveClientChangedAction), + ["session/activeClientToolsChanged"] = typeof(SessionActiveClientToolsChangedAction), + ["session/pendingMessageSet"] = typeof(SessionPendingMessageSetAction), + ["session/pendingMessageRemoved"] = typeof(SessionPendingMessageRemovedAction), + ["session/queuedMessagesReordered"] = typeof(SessionQueuedMessagesReorderedAction), + ["session/inputRequested"] = typeof(SessionInputRequestedAction), + ["session/inputAnswerChanged"] = typeof(SessionInputAnswerChangedAction), + ["session/inputCompleted"] = typeof(SessionInputCompletedAction), + ["session/customizationsChanged"] = typeof(SessionCustomizationsChangedAction), + ["session/customizationToggled"] = typeof(SessionCustomizationToggledAction), + ["session/customizationUpdated"] = typeof(SessionCustomizationUpdatedAction), + ["session/customizationRemoved"] = typeof(SessionCustomizationRemovedAction), + ["session/mcpServerStateChanged"] = typeof(SessionMcpServerStateChangedAction), + ["session/truncated"] = typeof(SessionTruncatedAction), + ["session/configChanged"] = typeof(SessionConfigChangedAction), + ["session/metaChanged"] = typeof(SessionMetaChangedAction), + ["session/toolCallContentChanged"] = typeof(SessionToolCallContentChangedAction), + ["changeset/statusChanged"] = typeof(ChangesetStatusChangedAction), + ["changeset/fileSet"] = typeof(ChangesetFileSetAction), + ["changeset/fileRemoved"] = typeof(ChangesetFileRemovedAction), + ["changeset/operationsChanged"] = typeof(ChangesetOperationsChangedAction), + ["changeset/operationStatusChanged"] = typeof(ChangesetOperationStatusChangedAction), + ["changeset/cleared"] = typeof(ChangesetClearedAction), + ["root/terminalsChanged"] = typeof(RootTerminalsChangedAction), + ["terminal/data"] = typeof(TerminalDataAction), + ["terminal/input"] = typeof(TerminalInputAction), + ["terminal/resized"] = typeof(TerminalResizedAction), + ["terminal/claimed"] = typeof(TerminalClaimedAction), + ["terminal/titleChanged"] = typeof(TerminalTitleChangedAction), + ["terminal/cwdChanged"] = typeof(TerminalCwdChangedAction), + ["terminal/exited"] = typeof(TerminalExitedAction), + ["terminal/cleared"] = typeof(TerminalClearedAction), + ["terminal/commandDetectionAvailable"] = typeof(TerminalCommandDetectionAvailableAction), + ["terminal/commandExecuted"] = typeof(TerminalCommandExecutedAction), + ["terminal/commandFinished"] = typeof(TerminalCommandFinishedAction), + ["resourceWatch/changed"] = typeof(ResourceWatchChangedAction), + ["annotations/set"] = typeof(AnnotationsSetAction), + ["annotations/removed"] = typeof(AnnotationsRemovedAction), + ["annotations/entrySet"] = typeof(AnnotationsEntrySetAction), + ["annotations/entryRemoved"] = typeof(AnnotationsEntryRemovedAction), + }, + allowUnknown: true) + { + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs new file mode 100644 index 00000000..61be5396 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -0,0 +1,1743 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +// ─── Enums ──────────────────────────────────────────────────────────── + +/// +/// Discriminant for reconnect result types. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ReconnectResultType +{ + [WireValue("replay")] + Replay, + [WireValue("snapshot")] + Snapshot, +} + +/// +/// Encoding of fetched content data. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ContentEncoding +{ + [WireValue("base64")] + Base64, + [WireValue("utf-8")] + Utf8, +} + +/// +/// The kind of completion items being requested. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum CompletionItemKind +{ + /// + /// Completions for the text of a {@link Message} the user is composing. + /// Each returned item carries an attachment that gets associated with the + /// message when accepted. + /// + [WireValue("userMessage")] + UserMessage, +} + +/// +/// Discriminant for {@link ResourceResolveResult.type}. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ResourceType +{ + [WireValue("file")] + File, + [WireValue("directory")] + Directory, + [WireValue("symlink")] + Symlink, +} + +/// +/// How {@link ResourceWriteParams.data} is placed within the target file. +/// +/// Each mode interprets {@link ResourceWriteParams.position} differently: +/// +/// - `truncate` (default): rooted at the **start** of the file. The file is +/// truncated at `position` (0 by default) and `data` is written from that +/// offset, so the resulting file is `existing[0..position] + data`. With +/// `position` omitted this is a full overwrite. +/// - `append`: rooted at the **end** of the file. `position` counts bytes +/// backwards from EOF, so `position: 0` (the default) writes at EOF — +/// POSIX append — and `position: 5` inserts `data` 5 bytes before the +/// current EOF, shifting those trailing 5 bytes after the inserted region. +/// The server MUST evaluate the effective EOF and write atomically with +/// respect to other appenders so concurrent `append` writes do not +/// clobber each other. +/// - `insert`: rooted at the **start** of the file. `position` (0 by default) +/// is the byte offset at which `data` is spliced in; bytes at or after +/// `position` are shifted right by `data.length`. `insert` always grows +/// the file — use `truncate` to overwrite bytes in place. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ResourceWriteMode +{ + [WireValue("truncate")] + Truncate, + [WireValue("append")] + Append, + [WireValue("insert")] + Insert, +} + +// ─── Command Payloads ───────────────────────────────────────────────── + +/// +/// Establishes a new connection and negotiates the protocol version. +/// This MUST be the first message sent by the client. +/// +public sealed class InitializeParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Protocol versions the client is willing to speak, ordered from most + /// preferred to least preferred. Each entry is a [SemVer](https://semver.org) + /// `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). + /// + /// The server selects one entry and returns it as `InitializeResult.protocolVersion`. + /// If the server cannot speak any of the offered versions, it MUST return + /// error code `-32005` (`UnsupportedProtocolVersion`). + /// + [JsonPropertyName("protocolVersions")] + public List ProtocolVersions { get; set; } = null!; + + /// + /// Unique client identifier + /// + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; + + /// + /// URIs to subscribe to during handshake + /// + [JsonPropertyName("initialSubscriptions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? InitialSubscriptions { get; set; } + + /// + /// IETF BCP 47 language tag indicating the client's preferred locale + /// (e.g. `"en-US"`, `"ja"`). The server SHOULD use this to localise + /// user-facing strings such as confirmation option labels. + /// + [JsonPropertyName("locale")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Locale { get; set; } + + /// + /// Optional client capability declarations. + /// + /// Servers SHOULD only advertise features whose corresponding client + /// capability is set here. Absent means "not declared" — the server + /// MUST assume the client does not support the feature. + /// + [JsonPropertyName("capabilities")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ClientCapabilities? Capabilities { get; set; } +} + +/// +/// Result of the `initialize` command. +/// +/// `protocolVersion` is the version the server has selected from the client's +/// `protocolVersions` list. The client and server MUST use this version for +/// the rest of the connection. If the server cannot speak any of the offered +/// versions it MUST return error code `-32005` (`UnsupportedProtocolVersion`) +/// instead of a result. +/// +public sealed class InitializeResult +{ + /// + /// Protocol version selected by the server. MUST be one of the entries in + /// `InitializeParams.protocolVersions`. Formatted as a [SemVer](https://semver.org) + /// `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). + /// + [JsonPropertyName("protocolVersion")] + public string ProtocolVersion { get; set; } = ""; + + /// + /// Current server sequence number + /// + [JsonPropertyName("serverSeq")] + public long ServerSeq { get; set; } + + /// + /// Snapshots for each `initialSubscriptions` URI + /// + [JsonPropertyName("snapshots")] + public List Snapshots { get; set; } = null!; + + /// + /// Suggested default directory for remote filesystem browsing + /// + [JsonPropertyName("defaultDirectory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultDirectory { get; set; } + + /// + /// Characters that, when typed in a {@link Message} input, SHOULD cause + /// the client to issue a `completions` request with + /// {@link CompletionItemKind.UserMessage}. Typically includes characters like + /// `'@'` or `'/'`. + /// + [JsonPropertyName("completionTriggerCharacters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CompletionTriggerCharacters { get; set; } + + /// + /// OTLP telemetry channels the host emits, if any. Each populated field is + /// either a literal `ahp-otlp:` channel URI or an RFC 6570 URI template a + /// client expands before subscribing (currently only the `logs` channel + /// defines a template variable, `{level}`, for subscriber-side severity + /// filtering). Clients MAY ignore signals they cannot process. + /// + [JsonPropertyName("telemetry")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TelemetryCapabilities? Telemetry { get; set; } +} + +/// +/// Optional capabilities a client declares during `initialize`. +/// +/// Each field is a presence flag: an empty object `{}` means "supported", +/// absence means "not supported". Sub-fields on individual capabilities +/// are reserved for future per-capability options. +/// +public sealed class ClientCapabilities +{ + /// + /// Client can render + /// [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. + /// it can host the View sandbox, run the `ui/*` protocol against it, + /// and forward `mcp://`-channel traffic on the App's behalf. + /// + /// Hosts SHOULD only populate + /// {@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`} + /// (and expose the corresponding + /// {@link McpServerCustomization.channel | `mcp://` channel}) when this + /// capability is declared. Clients that omit it MUST treat + /// App-bearing tool calls as ordinary MCP tool calls. + /// + [JsonPropertyName("mcpApps")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? McpApps { get; set; } +} + +/// +/// Re-establishes a dropped connection. The server replays missed actions or +/// provides fresh snapshots. +/// +public sealed class ReconnectParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Client identifier from the original connection + /// + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; + + /// + /// Last `serverSeq` the client received + /// + [JsonPropertyName("lastSeenServerSeq")] + public long LastSeenServerSeq { get; set; } + + /// + /// URIs the client was subscribed to + /// + [JsonPropertyName("subscriptions")] + public List Subscriptions { get; set; } = null!; +} + +/// +/// Reconnect result when the server can replay from the requested sequence. +/// +/// The server MUST include all replayed data in the response. +/// +public sealed class ReconnectReplayResult +{ + /// + /// Discriminant + /// + [JsonPropertyName("type")] + public ReconnectResultType Type { get; set; } + + /// + /// Missed action envelopes since `lastSeenServerSeq` + /// + [JsonPropertyName("actions")] + public List Actions { get; set; } = null!; + + /// + /// URIs from `ReconnectParams.subscriptions` that the server cannot resume. + /// This includes resources that no longer exist (e.g. disposed sessions or + /// terminals) as well as resources the client is no longer permitted to + /// observe. Clients SHOULD drop these from their local subscription set. + /// + [JsonPropertyName("missing")] + public List Missing { get; set; } = null!; +} + +/// +/// Reconnect result when the gap exceeds the replay buffer. +/// +public sealed class ReconnectSnapshotResult +{ + /// + /// Discriminant + /// + [JsonPropertyName("type")] + public ReconnectResultType Type { get; set; } + + /// + /// Fresh snapshots for each subscription + /// + [JsonPropertyName("snapshots")] + public List Snapshots { get; set; } = null!; +} + +/// +/// Subscribe to a URI-identified channel. +/// +/// A channel MAY have state associated with it (e.g. root, sessions, +/// terminals) or be stateless (pure pub/sub for streaming data). For +/// state-bearing channels the result includes a snapshot; for stateless +/// channels `snapshot` is omitted. +/// +public sealed class SubscribeParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; +} + +/// +/// Result of the `subscribe` command. +/// +/// `snapshot` is present when the subscribed channel has associated state, and +/// absent for stateless channels. +/// +public sealed class SubscribeResult +{ + /// + /// Snapshot of the subscribed channel's state (omitted for stateless channels) + /// + [JsonPropertyName("snapshot")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Snapshot? Snapshot { get; set; } +} + +/// +/// Creates a new session with the specified agent provider. +/// +/// If the session URI already exists, the server MUST return an error with code +/// `-32003` (`SessionAlreadyExists`). +/// +/// After creation, the client should subscribe to the session URI to receive state +/// updates. The server also broadcasts a `root/sessionAdded` notification to all +/// clients. +/// +public sealed class SessionForkSource +{ + /// + /// URI of the existing session to fork from + /// + [JsonPropertyName("session")] + public string Session { get; set; } = ""; + + /// + /// Turn ID in the source session; content up to and including this turn's response is copied + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; +} + +public sealed class CreateSessionParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Agent provider ID + /// + [JsonPropertyName("provider")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; set; } + + /// + /// Model selection (ID and optional model-specific configuration) + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; set; } + + /// + /// Initial custom agent selection for the new session. + /// + /// Omit to start the session with no custom agent selected (provider default). + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } + + /// + /// Working directory for the session + /// + [JsonPropertyName("workingDirectory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } + + /// + /// Fork from an existing session. The new session is populated with content + /// from the source session up to and including the specified turn's response. + /// + [JsonPropertyName("fork")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionForkSource? Fork { get; set; } + + /// + /// Agent-specific configuration values collected via `resolveSessionConfig`. + /// Keys and values correspond to the schema returned by the server. + /// + [JsonPropertyName("config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; set; } + + /// + /// Eagerly claim the active client role for the new session. + /// + /// When provided, the server initializes the session with this client as the + /// active client, equivalent to dispatching a `session/activeClientChanged` + /// action immediately after creation. The `clientId` MUST match the + /// `clientId` the creating client supplied in `initialize`. + /// + [JsonPropertyName("activeClient")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionActiveClient? ActiveClient { get; set; } +} + +/// +/// Disposes a session and cleans up server-side resources. +/// +/// The server broadcasts a `root/sessionRemoved` notification to all clients. +/// +public sealed class DisposeSessionParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; +} + +/// +/// Returns a list of session summaries. Used to populate session lists and sidebars. +/// +/// The session list is **not** part of the state tree because it can be arbitrarily +/// large. Clients fetch it imperatively and maintain a local cache updated by +/// `root/sessionAdded` and `root/sessionRemoved` notifications. +/// +public sealed class ListSessionsParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Optional filter criteria + /// + [JsonPropertyName("filter")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Filter { get; set; } +} + +/// +/// Result of the `listSessions` command. +/// +public sealed class ListSessionsResult +{ + /// + /// The list of session summaries. + /// + [JsonPropertyName("items")] + public List Items { get; set; } = null!; +} + +/// +/// Reads the content of a resource by URI. +/// +/// Content references keep the state tree small by storing large data (images, +/// long tool outputs) by reference rather than inline. +/// +/// Binary content (images, etc.) MUST use `base64` encoding. Text content MAY +/// use `utf-8` encoding. +/// +/// Like all `resource*` methods, `resourceRead` is symmetrical and MAY be +/// sent in either direction. Hosts use it to fetch content from a +/// client-published URI (e.g. `virtual://my-client/...` plugins); clients +/// use it to read host-side files. The receiver enforces access via the +/// same permission/`resourceRequest` flow regardless of which peer initiated. +/// +public sealed class ResourceReadParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Content URI from a `ContentRef` + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Preferred encoding for the returned data (default: server-chosen) + /// + [JsonPropertyName("encoding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ContentEncoding? Encoding { get; set; } +} + +/// +/// Result of the `resourceRead` command. +/// +/// The server SHOULD honor the `encoding` requested in the params. If the +/// server cannot provide the requested encoding, it MUST fall back to either +/// `base64` or `utf-8`. +/// +public sealed class ResourceReadResult +{ + /// + /// Content encoded as a string + /// + [JsonPropertyName("data")] + public string Data { get; set; } = ""; + + /// + /// How `data` is encoded + /// + [JsonPropertyName("encoding")] + public ContentEncoding Encoding { get; set; } + + /// + /// Content type (e.g. `"image/png"`, `"text/plain"`) + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } +} + +/// +/// Writes content to a file on the server's filesystem. +/// +/// Binary content (images, etc.) MUST use `base64` encoding. Text content MAY +/// use `utf-8` encoding. +/// +/// If the file does not exist, it is created. If the file already exists, the +/// effect on existing bytes depends on {@link ResourceWriteParams.mode}: +/// `truncate` (default) overwrites from the chosen offset onward, `append` +/// preserves all existing bytes and adds `data` at a position rooted at EOF, +/// and `insert` preserves all existing bytes and splices `data` in at an +/// offset rooted at the start of the file. +/// +/// Like all `resource*` methods, `resourceWrite` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceWriteParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Target file URI on the server filesystem + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Content encoded as a string + /// + [JsonPropertyName("data")] + public string Data { get; set; } = ""; + + /// + /// How `data` is encoded + /// + [JsonPropertyName("encoding")] + public ContentEncoding Encoding { get; set; } + + /// + /// Content type (e.g. `"text/plain"`, `"image/png"`) + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } + + /// + /// If `true`, the server MUST fail if the file already exists instead of + /// overwriting it. Useful for safe creation of new files. + /// + [JsonPropertyName("createOnly")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? CreateOnly { get; set; } + + /// + /// How `data` is placed within the target file. Defaults to `'truncate'` + /// (full overwrite) when omitted. See {@link ResourceWriteMode} for the + /// meaning of each mode and how it interprets {@link position}. + /// + [JsonPropertyName("mode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceWriteMode? Mode { get; set; } + + /// + /// Byte offset interpreted according to {@link mode}. Defaults to `0`. + /// - `truncate`: offset from the start of the file at which to truncate + /// before writing. + /// - `append`: bytes back from EOF at which to insert `data`. + /// - `insert`: offset from the start of the file at which to splice in + /// `data`. + /// + [JsonPropertyName("position")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Position { get; set; } + + /// + /// Optimistic-concurrency token previously returned by + /// {@link ResourceResolveResult.etag}. When set, the server MUST fail with + /// `Conflict` if the current `etag` does not match — preventing lost + /// updates between a `resourceResolve` and a subsequent `resourceWrite`. + /// + [JsonPropertyName("ifMatch")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IfMatch { get; set; } +} + +/// +/// Result of the `resourceWrite` command. +/// +/// An empty object on success. +/// +public sealed class ResourceWriteResult +{ +} + +/// +/// Lists directory entries at a file URI on the server's filesystem. +/// +/// This is intended for remote folder pickers and similar UI that needs to let +/// users navigate the server's local filesystem. +/// +/// The server MUST return success only if the target exists and is a directory. +/// If the target does not exist, is not a directory, or cannot be accessed, the +/// server MUST return a JSON-RPC error. +/// +/// Like all `resource*` methods, `resourceList` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceListParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Directory URI on the server filesystem + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; +} + +/// +/// Result of the `resourceList` command. +/// +public sealed class ResourceListResult +{ + /// + /// Entries directly contained in the requested directory + /// + [JsonPropertyName("entries")] + public List Entries { get; set; } = null!; +} + +/// +/// Directory entry returned by `resourceList`. +/// +public sealed class DirectoryEntry +{ + /// + /// Base name of the entry + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Whether the entry is a file or directory + /// + [JsonPropertyName("type")] + public string Type { get; set; } = ""; +} + +/// +/// Copies a resource from one URI to another on the server's filesystem. +/// +/// If the destination already exists, it is overwritten unless `failIfExists` +/// is set. +/// +/// Like all `resource*` methods, `resourceCopy` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceCopyParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Source URI to copy from + /// + [JsonPropertyName("source")] + public string Source { get; set; } = ""; + + /// + /// Destination URI to copy to + /// + [JsonPropertyName("destination")] + public string Destination { get; set; } = ""; + + /// + /// If `true`, the server MUST fail if the destination already exists instead + /// of overwriting it. + /// + [JsonPropertyName("failIfExists")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? FailIfExists { get; set; } +} + +/// +/// Result of the `resourceCopy` command. +/// +/// An empty object on success. +/// +public sealed class ResourceCopyResult +{ +} + +/// +/// Deletes a resource at a URI on the server's filesystem. +/// +/// Like all `resource*` methods, `resourceDelete` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceDeleteParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// URI of the resource to delete + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// If `true` and the target is a directory, delete it and all its contents + /// recursively. If `false` (default), deleting a non-empty directory MUST fail. + /// + [JsonPropertyName("recursive")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recursive { get; set; } +} + +/// +/// Result of the `resourceDelete` command. +/// +/// An empty object on success. +/// +public sealed class ResourceDeleteResult +{ +} + +/// +/// Moves (renames) a resource from one URI to another on the server's filesystem. +/// +/// If the destination already exists, it is overwritten unless `failIfExists` +/// is set. +/// +/// Like all `resource*` methods, `resourceMove` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceMoveParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Source URI to move from + /// + [JsonPropertyName("source")] + public string Source { get; set; } = ""; + + /// + /// Destination URI to move to + /// + [JsonPropertyName("destination")] + public string Destination { get; set; } = ""; + + /// + /// If `true`, the server MUST fail if the destination already exists instead + /// of overwriting it. + /// + [JsonPropertyName("failIfExists")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? FailIfExists { get; set; } +} + +/// +/// Result of the `resourceMove` command. +/// +/// An empty object on success. +/// +public sealed class ResourceMoveResult +{ +} + +/// +/// Resolves a resource — the combination of POSIX `stat` and `realpath`. +/// +/// `resourceResolve` returns metadata about the resource together with its +/// canonical URI after symlink resolution. Use this in place of any +/// `resourceExists` shim: a missing resource MUST surface as a `NotFound` +/// JSON-RPC error rather than a success with a sentinel value. Callers that +/// truly need a boolean check should attempt `resourceResolve` and treat +/// `NotFound` as "does not exist". +/// +/// Like all `resource*` methods, `resourceResolve` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceResolveParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// URI to resolve + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// When `true` (default), follow symlinks and report the metadata of the + /// link target — and set `uri` in the result to the canonical (realpath) + /// URI. When `false`, stat the link itself (lstat semantics) and report + /// `type: 'symlink'`. + /// + [JsonPropertyName("followSymlinks")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? FollowSymlinks { get; set; } +} + +/// +/// Result of the `resourceResolve` command. +/// +public sealed class ResourceResolveResult +{ + /// + /// Canonical URI after symlink resolution. Equal to the requested URI when + /// `followSymlinks` is `false` or the URI does not traverse a symlink. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Resource kind. + /// + [JsonPropertyName("type")] + public ResourceType Type { get; set; } + + /// + /// Size in bytes. Omitted for directories when the provider cannot + /// cheaply compute it. + /// + [JsonPropertyName("size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; set; } + + /// + /// Last-modified time in ISO 8601 format, when known. + /// + [JsonPropertyName("mtime")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Mtime { get; set; } + + /// + /// Creation time in ISO 8601 format, when known. + /// + [JsonPropertyName("ctime")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Ctime { get; set; } + + /// + /// Sniffed MIME type, when known (e.g. `"text/plain"`, `"image/png"`). + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } + + /// + /// Opaque per-provider version token. When present, pass it as + /// {@link ResourceWriteParams.ifMatch} on a subsequent `resourceWrite` to + /// detect concurrent modifications. + /// + [JsonPropertyName("etag")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Etag { get; set; } +} + +/// +/// Creates a directory on the server's filesystem with `mkdir -p` semantics. +/// +/// The server MUST create any missing parent directories. Creating a +/// directory that already exists is a no-op success. If `uri` already +/// exists but is **not** a directory, the server MUST fail with +/// `AlreadyExists`. +/// +/// Like all `resource*` methods, `resourceMkdir` is symmetrical and MAY be +/// sent in either direction. +/// +public sealed class ResourceMkdirParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Directory URI to create (parents created as needed). + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; +} + +/// +/// Result of the `resourceMkdir` command. +/// +/// An empty object on success. +/// +public sealed class ResourceMkdirResult +{ +} + +/// +/// Requests permission to access a resource on the receiver's filesystem. +/// +/// `resourceRequest` is symmetrical and MAY be sent in either direction: a +/// client asks the server to grant access to a server-side resource, or a +/// server asks the client to grant access to a client-side resource. The +/// receiver decides whether to allow, deny, or prompt the user for the +/// requested access. +/// +/// If the receiver denies access, it MUST respond with `PermissionDenied` +/// (-32009). The error data MAY include a `ResourceRequestParams` value +/// describing the access the caller would need to be granted for the +/// operation to succeed; see `PermissionDeniedErrorData` in +/// `types/errors.ts`. +/// +/// After a successful `resourceRequest`, the caller MAY use the corresponding +/// `resource*` commands (e.g. `resourceRead`, `resourceWrite`) to perform the +/// operation. Receivers MAY rescind access at any time by returning +/// `PermissionDenied` on subsequent operations. +/// +/// Either `read`, `write`, or both SHOULD be set to `true`. A request with +/// neither flag set is treated as `read: true` by receivers. +/// +public sealed class ResourceRequestParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Resource URI being requested. Typically a `file:` URI on the receiver's + /// filesystem, but any URI scheme that the receiver mediates access to is + /// allowed. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Whether the caller needs read access to the resource. + /// + [JsonPropertyName("read")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Read { get; set; } + + /// + /// Whether the caller needs write access to the resource. + /// + [JsonPropertyName("write")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Write { get; set; } +} + +/// +/// Result of the `resourceRequest` command. +/// +/// An empty object on success. +/// +public sealed class ResourceRequestResult +{ +} + +/// +/// Creates a resource watcher on the receiver's filesystem. +/// +/// The receiver allocates an `ahp-resource-watch:/<id>` channel URI and +/// returns it on {@link CreateResourceWatchResult.channel}. The caller then +/// [`subscribe`](./subscriptions)s to that channel to receive +/// `resourceWatch/changed` actions over the standard action envelope. +/// +/// The watch lifecycle is tied to subscription: when every subscriber has +/// unsubscribed (or the underlying connection drops), the receiver MUST +/// release the watcher. There is no explicit dispose command — `unsubscribe` +/// is the only handle the caller needs. +/// +/// Like the rest of the `resource*` family, `createResourceWatch` is +/// symmetrical and MAY be sent in either direction. Access is gated through +/// the same permission flow as `resourceRead`/`resourceWrite`. +/// +public sealed class CreateResourceWatchParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// URI to watch. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// If `true`, the receiver MUST report changes for descendants of `uri`. + /// If `false` (default), only changes to `uri` itself — and, when `uri` + /// is a directory, its direct children — are reported. + /// + [JsonPropertyName("recursive")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recursive { get; set; } + + /// + /// Glob patterns or paths relative to `uri` to exclude from reporting. + /// Wrapped in `{ items }` for forward compatibility. + /// + [JsonPropertyName("excludes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Excludes { get; set; } + + /// + /// Glob patterns or paths relative to `uri` to restrict reporting to. + /// Omit to report every change under `uri` subject to `excludes`. + /// Wrapped in `{ items }` for forward compatibility. + /// + [JsonPropertyName("includes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Includes { get; set; } +} + +/// +/// Result of the `createResourceWatch` command. +/// +public sealed class CreateResourceWatchResult +{ + /// + /// Receiver-assigned watch channel URI (`ahp-resource-watch:/<id>`). The + /// caller subscribes to this URI to start receiving change events and + /// unsubscribes to release the watcher. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; +} + +/// +/// Fetches historical turns for a session. Used for lazy loading of conversation +/// history. +/// +public sealed class FetchTurnsParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. + /// + [JsonPropertyName("before")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Before { get; set; } + + /// + /// Maximum number of turns to return. Server MAY impose its own upper bound. + /// + [JsonPropertyName("limit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Limit { get; set; } +} + +/// +/// Result of the `fetchTurns` command. +/// +public sealed class FetchTurnsResult +{ + /// + /// The requested turns, ordered oldest-first + /// + [JsonPropertyName("turns")] + public List Turns { get; set; } = null!; + + /// + /// Whether more turns exist before the returned range + /// + [JsonPropertyName("hasMore")] + public bool HasMore { get; set; } +} + +/// +/// Stop receiving updates for a channel. +/// +public sealed class UnsubscribeParams +{ + /// + /// Channel URI to unsubscribe from + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; +} + +/// +/// Fire-and-forget action dispatch (write-ahead). The client applies actions +/// optimistically to local state and the server echoes them back as an +/// {@link ActionEnvelope} once accepted. +/// +/// The client → server method is named `dispatchAction`; the server's reply +/// arrives on the server → client `action` notification (params: +/// {@link ActionEnvelope}). +/// +public sealed class DispatchActionParams +{ + /// + /// Channel URI this action targets + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Client sequence number + /// + [JsonPropertyName("clientSeq")] + public long ClientSeq { get; set; } + + /// + /// The action to dispatch + /// + [JsonPropertyName("action")] + public required StateAction Action { get; set; } +} + +/// +/// Pushes a Bearer token for a protected resource. The `resource` field MUST +/// match a `ProtectedResourceMetadata.resource` value declared by an agent +/// in `AgentInfo.protectedResources`. +/// +/// Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) +/// (Bearer Token Usage) semantics. The client obtains the token from the +/// authorization server(s) listed in the resource's metadata and pushes it +/// to the server via this command. +/// +public sealed class AuthenticateParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// The protected resource identifier. MUST match a `resource` value from + /// `ProtectedResourceMetadata` declared in `AgentInfo.protectedResources`. + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Bearer token obtained from the resource's authorization server + /// + [JsonPropertyName("token")] + public string Token { get; set; } = ""; +} + +/// +/// Result of the `authenticate` command. +/// +/// An empty object on success. If the token is invalid or the resource is +/// unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` +/// `-32007` or `InvalidParams` `-32602`). +/// +public sealed class AuthenticateResult +{ +} + +/// +/// Creates a new terminal on the server. +/// +/// After creation, the client should subscribe to the terminal URI to receive +/// state updates. The server dispatches `root/terminalsChanged` to update the +/// root terminal list. +/// +public sealed class CreateTerminalParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Initial owner of the terminal + /// + [JsonPropertyName("claim")] + public required TerminalClaim Claim { get; set; } + + /// + /// Human-readable terminal name + /// + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + + /// + /// Initial working directory URI + /// + [JsonPropertyName("cwd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cwd { get; set; } + + /// + /// Initial terminal width in columns + /// + [JsonPropertyName("cols")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Cols { get; set; } + + /// + /// Initial terminal height in rows + /// + [JsonPropertyName("rows")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Rows { get; set; } +} + +/// +/// Disposes a terminal and kills its process if still running. +/// +/// The server dispatches `root/terminalsChanged` to remove the terminal from +/// the root terminal list. +/// +public sealed class DisposeTerminalParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; +} + +/// +/// Iteratively resolves the session configuration schema. The client sends the +/// current partial session config and any user-filled metadata values. The server +/// returns a property schema describing what additional metadata is needed, +/// contextual to the current selections. +/// +/// The client calls this command whenever the user changes a significant input +/// (e.g. picks a working directory, toggles a property). Each response returns +/// the full current property set (not a delta). The returned `values` contain +/// server-resolved defaults to pass to `createSession`. +/// +public sealed class ResolveSessionConfigParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Agent provider ID + /// + [JsonPropertyName("provider")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; set; } + + /// + /// Working directory for the session + /// + [JsonPropertyName("workingDirectory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } + + /// + /// Current user-filled configuration values + /// + [JsonPropertyName("config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; set; } +} + +/// +/// Result of the `resolveSessionConfig` command. +/// +public sealed class ResolveSessionConfigResult +{ + /// + /// JSON Schema describing available configuration properties given the current context + /// + [JsonPropertyName("schema")] + public required SessionConfigSchema Schema { get; set; } + + /// + /// Current configuration values (echoed back with server-resolved defaults applied) + /// + [JsonPropertyName("values")] + public Dictionary Values { get; set; } = null!; +} + +/// +/// Queries the server for allowed values of a dynamic session config property. +/// +/// Used when a property in the schema returned by `resolveSessionConfig` has +/// `enumDynamic: true`. The client sends a search query and receives matching +/// values with display metadata. +/// +public sealed class SessionConfigCompletionsParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Agent provider ID + /// + [JsonPropertyName("provider")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; set; } + + /// + /// Working directory for the session + /// + [JsonPropertyName("workingDirectory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } + + /// + /// Current user-filled configuration values (provides context for the query) + /// + [JsonPropertyName("config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; set; } + + /// + /// Property id from the schema to query values for + /// + [JsonPropertyName("property")] + public string Property { get; set; } = ""; + + /// + /// Search filter text (empty or omitted returns default/recent values) + /// + [JsonPropertyName("query")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Query { get; set; } +} + +/// +/// Result of the `sessionConfigCompletions` command. +/// +public sealed class SessionConfigCompletionsResult +{ + /// + /// Matching value items + /// + [JsonPropertyName("items")] + public List Items { get; set; } = null!; +} + +/// +/// A single value item returned by `sessionConfigCompletions`. +/// +public sealed class SessionConfigValueItem +{ + /// + /// The value to store in config + /// + [JsonPropertyName("value")] + public string Value { get; set; } = ""; + + /// + /// Human-readable display label + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// Optional secondary description + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } +} + +/// +/// Requests completion items for a partially-typed input (e.g. a user message +/// the user is currently composing). Used to power `@`-mention pickers, +/// file/symbol references, and similar inline-completion experiences. +/// +/// Servers SHOULD treat this command as best-effort and return promptly. The +/// client SHOULD debounce calls to avoid flooding the server with requests on +/// every keystroke. +/// +public sealed class CompletionsParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// What kind of completion is being requested. + /// + [JsonPropertyName("kind")] + public CompletionItemKind Kind { get; set; } + + /// + /// The complete text of the input being completed (e.g. the full user + /// message text typed so far). + /// + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + /// + /// The character offset within `text` at which the completion is requested, + /// measured in UTF-16 code units. MUST satisfy `0 <= offset <= text.length`. + /// + [JsonPropertyName("offset")] + public long Offset { get; set; } +} + +/// +/// A single completion item returned by the `completions` command. +/// +/// When the user accepts an item, the client SHOULD: +/// 1. Replace the range `[rangeStart, rangeEnd)` in the input with `insertText` +/// (or insert `insertText` at the cursor when the range is omitted). +/// 2. Associate the item's `attachment` with the resulting {@link Message}. +/// +public sealed class CompletionItem +{ + /// + /// The text inserted into the input when this item is accepted. + /// + [JsonPropertyName("insertText")] + public string InsertText { get; set; } = ""; + + /// + /// If defined, the start of the range in the input's `text` that is replaced + /// by `insertText`. The range is the half-open interval + /// `[rangeStart, rangeEnd)` of character offsets, measured in UTF-16 code + /// units. + /// + /// When omitted, the client SHOULD insert `insertText` at the cursor. + /// + /// Note: this range refers to positions in the *current* input. The + /// attachment's own `rangeStart`/`rangeEnd` (when present) refer to + /// positions in the final {@link Message.text} after the item is + /// accepted. + /// + [JsonPropertyName("rangeStart")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? RangeStart { get; set; } + + /// + /// The end of the range in the input's `text` that is replaced by + /// `insertText`. See {@link rangeStart}. + /// + [JsonPropertyName("rangeEnd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? RangeEnd { get; set; } + + /// + /// The attachment associated with this completion item. + /// + [JsonPropertyName("attachment")] + public required MessageAttachment Attachment { get; set; } +} + +/// +/// Result of the `completions` command. +/// +public sealed class CompletionsResult +{ + /// + /// The completion items, in the order the server suggests displaying them. + /// + [JsonPropertyName("items")] + public List Items { get; set; } = null!; +} + +/// +/// Invokes a server-defined {@link ChangesetOperation} against a changeset, +/// a single file, or a line range. +/// +/// The server validates that `operationId` exists in the changeset's +/// current `operations` list and that the requested `target.kind` is +/// contained in the operation's `scopes`. Invalid combinations result in a +/// JSON-RPC error. +/// +/// State changes resulting from invocation flow back through the normal +/// `changeset/*` action stream on the relevant changeset URIs. Clients +/// SHOULD NOT synthesise local optimistic changes for invocations unless +/// the server explicitly opts in via a future capability. +/// +public sealed class InvokeChangesetOperationParams +{ + /// + /// Channel URI this command targets. + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Matches {@link ChangesetOperation.id} from the changeset's `operations` list. + /// + [JsonPropertyName("operationId")] + public string OperationId { get; set; } = ""; + + /// + /// Target of the operation. Required iff the chosen scope is + /// `'resource'` or `'range'`. Omit for changeset-scoped operations. + /// + [JsonPropertyName("target")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesetOperationTarget? Target { get; set; } +} + +/// +/// Result of the {@link InvokeChangesetOperationParams | `invokeChangesetOperation`} +/// command. +/// +/// Success is implicit: the server returns this result when it accepted +/// the operation. Failure is signalled by rejecting the JSON-RPC request +/// with an appropriate error code, not by any field on this result. The +/// operation MAY still produce subsequent failure feedback through the +/// {@link ChangesetStatusChangedAction | `changeset/statusChanged`} stream. +/// +public sealed class InvokeChangesetOperationResult +{ + /// + /// Optional human-readable message describing the result. + /// + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? Message { get; set; } + + /// + /// Optional follow-up: a URI to open (e.g. a PR), a content ref, etc. + /// + [JsonPropertyName("followUp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesetOperationFollowUp? FollowUp { get; set; } +} + +/// +/// Optional follow-up surfaced by the server after an operation completes — +/// a {@link ContentRef} the client can fetch and display. +/// +/// Set `external` to `true` to open the content in the user's preferred +/// external handler (e.g. browser); otherwise the client is expected to +/// surface it inline. +/// +public sealed class ChangesetOperationFollowUp +{ + [JsonPropertyName("content")] + public required ContentRef Content { get; set; } + + /// + /// When `true`, open in an external handler rather than inline. + /// + [JsonPropertyName("external")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? External { get; set; } +} + +// ─── ReconnectResult Union ──────────────────────────────────────────── + +/// +/// ReconnectResult is the result of the `reconnect` command. +/// +[JsonConverter(typeof(ReconnectResultConverter))] +public sealed class ReconnectResult : AhpUnion +{ + /// Creates an empty ReconnectResult (no active variant). + public ReconnectResult() { } + + /// Creates a ReconnectResult wrapping the given variant value. + public ReconnectResult(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ReconnectResult discriminated union. +internal sealed class ReconnectResultConverter : UnionConverter +{ + public ReconnectResultConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["replay"] = typeof(ReconnectReplayResult), + ["snapshot"] = typeof(ReconnectSnapshotResult), + }, + allowUnknown: false) + { + } +} + +// ─── Changeset Operation Unions ─────────────────────────────────────── + +/// +/// ChangesetOperationTarget identifies the file or range a +/// ChangesetOperation should act on. +/// +[JsonConverter(typeof(ChangesetOperationTargetConverter))] +public sealed class ChangesetOperationTarget : AhpUnion +{ + public ChangesetOperationTarget() { } + public ChangesetOperationTarget(object? value) : base(value) { } +} + +/// Targets an entire resource. +public sealed class ChangesetOperationResourceTarget +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = "resource"; + + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + [JsonPropertyName("side")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; set; } +} + +/// Targets a range within a resource. +public sealed class ChangesetOperationRangeTarget +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = "range"; + + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + [JsonPropertyName("side")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; set; } + + [JsonPropertyName("range")] + public ChangesetOperationTargetRange Range { get; set; } = new(); +} + +/// The [start, end] index pair for a range target. +public sealed class ChangesetOperationTargetRange +{ + [JsonPropertyName("start")] + public long Start { get; set; } + + [JsonPropertyName("end")] + public long End { get; set; } +} + +/// System.Text.Json converter for the ChangesetOperationTarget union. +internal sealed class ChangesetOperationTargetConverter : UnionConverter +{ + public ChangesetOperationTargetConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["resource"] = typeof(ChangesetOperationResourceTarget), + ["range"] = typeof(ChangesetOperationRangeTarget), + }, + allowUnknown: false) + { + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs new file mode 100644 index 00000000..37f82b33 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs @@ -0,0 +1,69 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +/// Standard JSON-RPC 2.0 error codes. +public static class JsonRpcErrorCodes +{ + /// The request body was invalid JSON. + public const int ParseError = -32700; + + /// The payload was not a valid JSON-RPC request. + public const int InvalidRequest = -32600; + + /// The requested method does not exist on the server. + public const int MethodNotFound = -32601; + + /// The method parameters did not match the declared schema. + public const int InvalidParams = -32602; + + /// An unspecified server failure. + public const int InternalError = -32603; +} + +/// AHP application-specific error codes (above the JSON-RPC reserved range). +public static class AhpErrorCodes +{ + public const int SessionNotFound = -32001; + public const int ProviderNotFound = -32002; + public const int SessionAlreadyExists = -32003; + public const int TurnInProgress = -32004; + public const int UnsupportedProtocolVersion = -32005; + public const int ContentNotFound = -32006; + public const int AuthRequired = -32007; + public const int NotFound = -32008; + public const int PermissionDenied = -32009; + public const int AlreadyExists = -32010; +} + +/// Detail payload of an AuthRequired (-32007) error. +public sealed class AuthRequiredErrorData +{ + [JsonPropertyName("resources")] + public List Resources { get; set; } = new(); +} + +/// Detail payload of a PermissionDenied (-32009) error. +public sealed class PermissionDeniedErrorData +{ + [JsonPropertyName("request")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceRequestParams? Request { get; set; } +} + +/// Detail payload of an UnsupportedProtocolVersion (-32005) error. +public sealed class UnsupportedProtocolVersionErrorData +{ + [JsonPropertyName("supportedVersions")] + public List SupportedVersions { get; set; } = new(); +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs new file mode 100644 index 00000000..95f5370b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs @@ -0,0 +1,151 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +/// The canonical JSON-RPC version literal ("2.0"). +public static class JsonRpc +{ + /// The sole allowed value of the jsonrpc field. + public const string Version = "2.0"; +} + +/// A JSON-RPC 2.0 request (method + id). +public sealed class JsonRpcRequest +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("params")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Params { get; set; } +} + +/// A JSON-RPC 2.0 success response. +public sealed class JsonRpcSuccessResponse +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("result")] + public JsonElement Result { get; set; } +} + +/// A JSON-RPC 2.0 error response. +public sealed class JsonRpcErrorResponse +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("error")] + public JsonRpcErrorObject Error { get; set; } = new(); +} + +/// The standard JSON-RPC 2.0 error object. +public sealed class JsonRpcErrorObject +{ + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Data { get; set; } +} + +/// A JSON-RPC 2.0 notification (method, no id). +public sealed class JsonRpcNotification +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("params")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Params { get; set; } +} + +/// +/// A discriminated union over the four JSON-RPC message shapes. The active +/// variant is chosen by JSON-RPC 2.0's shape rules: +/// request (id + method), notification (method, no id), +/// success-response (id + result), error-response (id + error). +/// +[JsonConverter(typeof(JsonRpcMessageConverter))] +public sealed class JsonRpcMessage +{ + public JsonRpcRequest? Request { get; set; } + public JsonRpcSuccessResponse? SuccessResponse { get; set; } + public JsonRpcErrorResponse? ErrorResponse { get; set; } + public JsonRpcNotification? Notification { get; set; } +} + +/// System.Text.Json converter for the shape-probed JsonRpcMessage union. +internal sealed class JsonRpcMessageConverter : JsonConverter +{ + public override JsonRpcMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + bool hasMethod = root.TryGetProperty("method", out _); + bool hasId = root.TryGetProperty("id", out _); + bool hasResult = root.TryGetProperty("result", out _); + bool hasError = root.TryGetProperty("error", out _); + var msg = new JsonRpcMessage(); + if (hasMethod && hasId) + { + msg.Request = root.Deserialize(options); + } + else if (hasMethod) + { + msg.Notification = root.Deserialize(options); + } + else if (hasError) + { + msg.ErrorResponse = root.Deserialize(options); + } + else if (hasResult) + { + msg.SuccessResponse = root.Deserialize(options); + } + else + { + throw new JsonException("JSON-RPC message has no method/result/error"); + } + return msg; + } + + public override void Write(Utf8JsonWriter writer, JsonRpcMessage value, JsonSerializerOptions options) + { + if (value.Request is not null) { JsonSerializer.Serialize(writer, value.Request, options); return; } + if (value.SuccessResponse is not null) { JsonSerializer.Serialize(writer, value.SuccessResponse, options); return; } + if (value.ErrorResponse is not null) { JsonSerializer.Serialize(writer, value.ErrorResponse, options); return; } + if (value.Notification is not null) { JsonSerializer.Serialize(writer, value.Notification, options); return; } + writer.WriteNullValue(); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs new file mode 100644 index 00000000..424da02d --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs @@ -0,0 +1,350 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +// ─── Enums ──────────────────────────────────────────────────────────── + +/// +/// Reason why authentication is required. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum AuthRequiredReason +{ + /// + /// The client has not yet authenticated for the resource + /// + [WireValue("required")] + Required, + /// + /// A previously valid token has expired or been revoked + /// + [WireValue("expired")] + Expired, +} + +// ─── Notification Payloads ──────────────────────────────────────────── + +/// +/// Broadcast to all clients subscribed to the root channel when a new session +/// is created. +/// +public sealed class SessionAddedParams +{ + /// + /// Channel URI this notification belongs to (the root channel) + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// Summary of the new session + /// + [JsonPropertyName("summary")] + public required SessionSummary Summary { get; set; } +} + +/// +/// Broadcast to all clients subscribed to the root channel when a session is +/// disposed. +/// +public sealed class SessionRemovedParams +{ + /// + /// Channel URI this notification belongs to (the root channel) + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// URI of the removed session + /// + [JsonPropertyName("session")] + public string Session { get; set; } = ""; +} + +/// +/// Broadcast to all clients subscribed to the root channel when an existing +/// session's summary changes (title, status, `modifiedAt`, model, working +/// directory, read/done state, or diff statistics). +/// +/// This notification lets clients that maintain a cached session list — for +/// example, the result of a previous `listSessions()` call — stay in sync with +/// in-flight sessions without having to subscribe to every session URI +/// individually. It is complementary to, not a replacement for, +/// `root/sessionAdded` and `root/sessionRemoved`: those signal lifecycle +/// (creation/disposal), while this signals summary-level mutations on an +/// already-known session. +/// +/// Semantics: +/// +/// - Only fields present in `changes` have new values; omitted fields are +/// unchanged on the client's cached summary. +/// - Identity fields (`resource`, `provider`, `createdAt`) never change and +/// are not carried. +/// - Like all protocol notifications, this is ephemeral: it is **not** +/// replayed on reconnect. On reconnect, clients should re-fetch the full +/// catalog via `listSessions()` as usual. +/// - The server SHOULD emit this notification whenever any mutable field on +/// {@link SessionSummary | `SessionSummary`} changes for a session the +/// server has surfaced via `listSessions()` or `root/sessionAdded`. +/// Servers MAY coalesce or debounce updates for noisy fields (for example, +/// `modifiedAt` bumps while a turn is streaming) at their discretion. +/// - Clients that have no cached entry for `session` MAY ignore the +/// notification; it is not a substitute for `root/sessionAdded`. +/// +public sealed class SessionSummaryChangedParams +{ + /// + /// Channel URI this notification belongs to (the root channel) + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// URI of the session whose summary changed + /// + [JsonPropertyName("session")] + public string Session { get; set; } = ""; + + /// + /// Mutable summary fields that changed; omitted fields are unchanged. + /// + /// Identity fields (`resource`, `provider`, `createdAt`) never change and + /// MUST be omitted by senders; receivers SHOULD ignore them if present. + /// + [JsonPropertyName("changes")] + public required PartialSessionSummary Changes { get; set; } +} + +/// +/// Sent by the server when a protected resource requires (re-)authentication. +/// +/// This notification MAY be associated with any channel — for example, an +/// agent advertised on the root channel, or a per-session resource. The +/// `channel` field identifies the subscription the auth requirement belongs +/// to; the `resource` field carries the OAuth-protected resource identifier +/// (per RFC 9728). +/// +/// Clients should obtain a fresh token and push it via the `authenticate` +/// command. +/// +public sealed class AuthRequiredParams +{ + /// + /// Channel URI this notification belongs to + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// The protected resource identifier that requires authentication + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Why authentication is required + /// + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AuthRequiredReason? Reason { get; set; } +} + +/// +/// Delivers a batch of OTLP log records to a client subscribed to the host's +/// logs channel (advertised on `TelemetryCapabilities.logs`). +/// +/// The `payload` field is an OTLP/JSON `ExportLogsServiceRequest` value +/// verbatim — i.e. an object of shape `{ resourceLogs: ResourceLogs[] }` as +/// defined by [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/logs/v1/logs_service.proto). +/// AHP does not redeclare the OTLP type system; clients SHOULD use an +/// OpenTelemetry SDK or schema to parse it. +/// +/// Like all stateless-channel notifications, this is ephemeral: it is not +/// replayed on reconnect. Subscribers receive only batches emitted after +/// their `subscribe` succeeds. +/// +public sealed class OtlpExportLogsParams +{ + /// + /// Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.logs`). + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// OTLP/JSON `ExportLogsServiceRequest` value. The top-level field is + /// `resourceLogs: ResourceLogs[]`; nested shapes are defined by + /// opentelemetry-proto and are not redeclared here. + /// + [JsonPropertyName("payload")] + public Dictionary Payload { get; set; } = null!; +} + +/// +/// Delivers a batch of OTLP spans to a client subscribed to the host's +/// traces channel (advertised on `TelemetryCapabilities.traces`). +/// +/// The `payload` field is an OTLP/JSON `ExportTraceServiceRequest` value +/// verbatim — i.e. an object of shape `{ resourceSpans: ResourceSpans[] }` +/// as defined by [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/trace/v1/trace_service.proto). +/// +public sealed class OtlpExportTracesParams +{ + /// + /// Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.traces`). + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// OTLP/JSON `ExportTraceServiceRequest` value. The top-level field is + /// `resourceSpans: ResourceSpans[]`; nested shapes are defined by + /// opentelemetry-proto and are not redeclared here. + /// + [JsonPropertyName("payload")] + public Dictionary Payload { get; set; } = null!; +} + +/// +/// Delivers a batch of OTLP metric data points to a client subscribed to +/// the host's metrics channel (advertised on `TelemetryCapabilities.metrics`). +/// +/// The `payload` field is an OTLP/JSON `ExportMetricsServiceRequest` value +/// verbatim — i.e. an object of shape `{ resourceMetrics: ResourceMetrics[] }` +/// as defined by [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/metrics/v1/metrics_service.proto). +/// +public sealed class OtlpExportMetricsParams +{ + /// + /// Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.metrics`). + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + /// + /// OTLP/JSON `ExportMetricsServiceRequest` value. The top-level field is + /// `resourceMetrics: ResourceMetrics[]`; nested shapes are defined by + /// opentelemetry-proto and are not redeclared here. + /// + [JsonPropertyName("payload")] + public Dictionary Payload { get; set; } = null!; +} + +// ─── Partial Summaries ──────────────────────────────────────────────── + +/// +/// Partial equivalent of SessionSummary — every field is optional for delta updates. +/// +public sealed class PartialSessionSummary +{ + /// + /// Session URI + /// + [JsonPropertyName("resource")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Resource { get; set; } + + /// + /// Agent provider ID + /// + [JsonPropertyName("provider")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; set; } + + /// + /// Session title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Current session status + /// + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionStatus? Status { get; set; } + + /// + /// Human-readable description of what the session is currently doing + /// + [JsonPropertyName("activity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; set; } + + /// + /// Creation timestamp + /// + [JsonPropertyName("createdAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? CreatedAt { get; set; } + + /// + /// Last modification timestamp + /// + [JsonPropertyName("modifiedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ModifiedAt { get; set; } + + /// + /// Server-owned project for this session + /// + [JsonPropertyName("project")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProjectInfo? Project { get; set; } + + /// + /// Currently selected model + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; set; } + + /// + /// Currently selected custom agent. + /// + /// Absent (`undefined`) means no custom agent is selected for this session + /// — the session uses the provider's default behavior. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } + + /// + /// The working directory URI for this session + /// + [JsonPropertyName("workingDirectory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } + + /// + /// Aggregate summary of file changes associated with this session. Servers + /// may populate this to give clients a quick at-a-glance view of the + /// session's footprint (e.g., for list rendering) without requiring the + /// client to subscribe to a changeset. + /// + [JsonPropertyName("changes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesSummary? Changes { get; set; } + + /// + /// Lightweight summary of this session's inline annotations channel + /// (`ahp-session:/<uuid>/annotations`). Surfaced so badge UI can render + /// annotation / entry counts without subscribing. Absent when the session + /// does not expose an annotations channel. + /// + [JsonPropertyName("annotations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AnnotationsSummary? Annotations { get; set; } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs new file mode 100644 index 00000000..15265f0d --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -0,0 +1,5788 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +// ─── Enums ──────────────────────────────────────────────────────────── + +/// +/// Policy configuration state for a model. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum PolicyState +{ + [WireValue("enabled")] + Enabled, + [WireValue("disabled")] + Disabled, + [WireValue("unconfigured")] + Unconfigured, +} + +/// +/// Discriminant for pending message kinds. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum PendingMessageKind +{ + /// + /// Injected into the current turn at a convenient point + /// + [WireValue("steering")] + Steering, + /// + /// Sent automatically as a new turn after the current turn finishes + /// + [WireValue("queued")] + Queued, +} + +/// +/// Session initialization state. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum SessionLifecycle +{ + [WireValue("creating")] + Creating, + [WireValue("ready")] + Ready, + [WireValue("creationFailed")] + CreationFailed, +} + +/// +/// Bitset of summary-level session status flags. +/// +/// Use bitwise checks instead of equality for non-terminal activity. For example, +/// `status & SessionStatus.InProgress` matches both ordinary in-progress turns +/// and turns that are paused waiting for input. +/// +[Flags] +public enum SessionStatus : uint +{ + /// + /// Session is idle — no turn is active. + /// + Idle = 1, + /// + /// Session ended with an error. + /// + Error = 2, + /// + /// A turn is actively streaming. + /// + InProgress = 8, + /// + /// A turn is in progress but blocked waiting for user input or tool confirmation. + /// + InputNeeded = 24, + /// + /// The client has viewed this session since its last modification. + /// + IsRead = 32, + /// + /// The session has been archived by the client. + /// + IsArchived = 64, +} + +/// +/// Answer lifecycle state. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum SessionInputAnswerState +{ + [WireValue("draft")] + Draft, + [WireValue("submitted")] + Submitted, + [WireValue("skipped")] + Skipped, +} + +/// +/// Answer value kind. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum SessionInputAnswerValueKind +{ + [WireValue("text")] + Text, + [WireValue("number")] + Number, + [WireValue("boolean")] + Boolean, + [WireValue("selected")] + Selected, + [WireValue("selected-many")] + SelectedMany, +} + +/// +/// Question/input control kind. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum SessionInputQuestionKind +{ + [WireValue("text")] + Text, + [WireValue("number")] + Number, + [WireValue("integer")] + Integer, + [WireValue("boolean")] + Boolean, + [WireValue("single-select")] + SingleSelect, + [WireValue("multi-select")] + MultiSelect, +} + +/// +/// How a client completed an input request. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum SessionInputResponseKind +{ + [WireValue("accept")] + Accept, + [WireValue("decline")] + Decline, + [WireValue("cancel")] + Cancel, +} + +/// +/// How a turn ended. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum TurnState +{ + [WireValue("complete")] + Complete, + [WireValue("cancelled")] + Cancelled, + [WireValue("error")] + Error, +} + +/// +/// Discriminant for {@link MessageAttachment} variants. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum MessageAttachmentKind +{ + /// + /// A simple, opaque attachment whose representation is described by the producer. + /// + [WireValue("simple")] + Simple, + /// + /// An attachment whose data is embedded inline as a base64 string. + /// + [WireValue("embeddedResource")] + EmbeddedResource, + /// + /// An attachment that references a resource by URI. + /// + [WireValue("resource")] + Resource, + /// + /// An attachment that references annotations on an annotations channel. + /// + [WireValue("annotations")] + Annotations, +} + +/// +/// Discriminant for response part types. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ResponsePartKind +{ + [WireValue("markdown")] + Markdown, + [WireValue("contentRef")] + ContentRef, + [WireValue("toolCall")] + ToolCall, + [WireValue("reasoning")] + Reasoning, + [WireValue("systemNotification")] + SystemNotification, +} + +/// +/// Status of a tool call in the lifecycle state machine. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ToolCallStatus +{ + [WireValue("streaming")] + Streaming, + [WireValue("pending-confirmation")] + PendingConfirmation, + [WireValue("running")] + Running, + [WireValue("pending-result-confirmation")] + PendingResultConfirmation, + [WireValue("completed")] + Completed, + [WireValue("cancelled")] + Cancelled, +} + +/// +/// How a tool call was confirmed for execution. +/// +/// - `NotNeeded` — No confirmation required (auto-approved) +/// - `UserAction` — User explicitly approved +/// - `Setting` — Approved by a persistent user setting +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ToolCallConfirmationReason +{ + [WireValue("not-needed")] + NotNeeded, + [WireValue("user-action")] + UserAction, + [WireValue("setting")] + Setting, +} + +/// +/// Why a tool call was cancelled. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ToolCallCancellationReason +{ + [WireValue("denied")] + Denied, + [WireValue("skipped")] + Skipped, + [WireValue("result-denied")] + ResultDenied, +} + +/// +/// Whether a confirmation option represents an approval or denial action. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ConfirmationOptionKind +{ + [WireValue("approve")] + Approve, + [WireValue("deny")] + Deny, +} + +[JsonConverter(typeof(WireEnumConverter))] +public enum ToolCallContributorKind +{ + [WireValue("client")] + Client, + [WireValue("mcp")] + MCP, +} + +/// +/// Discriminant for tool result content types. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ToolResultContentType +{ + [WireValue("text")] + Text, + [WireValue("embeddedResource")] + EmbeddedResource, + [WireValue("resource")] + Resource, + [WireValue("fileEdit")] + FileEdit, + [WireValue("terminal")] + Terminal, + [WireValue("subagent")] + Subagent, +} + +/// +/// Discriminant for the kind of customization. +/// +/// Top-level entries in {@link SessionState.customizations} and +/// {@link AgentInfo.customizations} are either container customizations +/// ({@link CustomizationType.Plugin | `Plugin`} or +/// {@link CustomizationType.Directory | `Directory`}) or +/// {@link CustomizationType.McpServer | `McpServer`} entries surfaced +/// directly by the host. The remaining types appear only as children of +/// a container. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum CustomizationType +{ + [WireValue("plugin")] + Plugin, + [WireValue("directory")] + Directory, + [WireValue("agent")] + Agent, + [WireValue("skill")] + Skill, + [WireValue("prompt")] + Prompt, + [WireValue("rule")] + Rule, + [WireValue("hook")] + Hook, + [WireValue("mcpServer")] + McpServer, +} + +/// +/// Discriminant values for {@link CustomizationLoadState}. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum CustomizationLoadStatus +{ + [WireValue("loading")] + Loading, + [WireValue("loaded")] + Loaded, + [WireValue("degraded")] + Degraded, + [WireValue("error")] + Error, +} + +/// +/// Discriminant for terminal claim kinds. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum TerminalClaimKind +{ + [WireValue("client")] + Client, + [WireValue("session")] + Session, +} + +/// +/// Discriminant for the {@link McpServerState} union. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum McpServerStatus +{ + /// + /// Server has been registered but is not yet running. + /// + [WireValue("starting")] + Starting, + /// + /// Server is running and serving requests. + /// + [WireValue("ready")] + Ready, + /// + /// Server is reachable but requires additional authentication before it + /// can start, or before it can serve a particular request. Carries the + /// RFC 9728 Protected Resource Metadata the client needs to obtain a + /// token; the client then pushes the token via the existing + /// `authenticate` command. + /// + [WireValue("authRequired")] + AuthRequired, + /// + /// Server failed to start, crashed, or otherwise transitioned to a fatal error. + /// + [WireValue("error")] + Error, + /// + /// Server has been shut down. + /// + [WireValue("stopped")] + Stopped, +} + +/// +/// Why an MCP server is currently in the {@link McpServerStatus.AuthRequired} +/// state. Mirrors the three failure modes defined by the +/// [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization.md). +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum McpAuthRequiredReason +{ + /// + /// No token has been provided yet (HTTP 401, no prior token). + /// + [WireValue("required")] + Required, + /// + /// A previously valid token expired or was revoked (HTTP 401). + /// + [WireValue("expired")] + Expired, + /// + /// Step-up auth: a token is present but its scopes are insufficient for + /// the requested operation (HTTP 403 with + /// `WWW-Authenticate: Bearer error="insufficient_scope"`). + /// + /// Unlike {@link Required} and {@link Expired} — which typically surface + /// before any tool work is in flight — `InsufficientScope` is almost + /// always triggered by an MCP request issued mid-turn (a `tools/call`, + /// `resources/read`, etc.). The host SHOULD pair the + /// {@link McpServerAuthRequiredState} transition with + /// {@link SessionStatus.InputNeeded} on + /// {@link SessionSummary.status | the session} so the activity becomes + /// visible at the session-summary level, and clients SHOULD watch for + /// this kind on any + /// {@link McpServerCustomization | MCP server} backing a running tool + /// call so they can present an explicit "grant more access" affordance + /// tied to the blocked tool call. + /// + [WireValue("insufficientScope")] + InsufficientScope, +} + +/// +/// Computation lifecycle of a {@link ChangesetState}. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ChangesetStatus +{ + /// + /// The server is still computing the contents of this changeset. + /// + [WireValue("computing")] + Computing, + /// + /// The changeset has been fully computed and is up-to-date. + /// + [WireValue("ready")] + Ready, + /// + /// Computation failed. The cause is described by + /// {@link ChangesetState.error}. + /// + [WireValue("error")] + Error, +} + +/// +/// Execution lifecycle of a {@link ChangesetOperation}. +/// +/// An operation is invoked imperatively via `invokeChangesetOperation`, but +/// its progress and outcome are reflected back into changeset state so that +/// every subscriber observes a consistent view (e.g. a spinner on a "Create +/// Pull Request" button, or an inline error after a failed "revert"). +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ChangesetOperationStatus +{ + /// + /// The operation is ready to be invoked. This is the default when + /// {@link ChangesetOperation.status} is omitted. + /// + [WireValue("idle")] + Idle, + /// + /// An invocation of this operation is currently in flight. + /// + [WireValue("running")] + Running, + /// + /// The most recent invocation failed. The cause is described by + /// {@link ChangesetOperation.error}. + /// + [WireValue("error")] + Error, +} + +/// +/// Where a {@link ChangesetOperation} can be invoked. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ChangesetOperationScope +{ + /// + /// Applies to the whole changeset. + /// + [WireValue("changeset")] + Changeset, + /// + /// Applies to a single file within the changeset. + /// + [WireValue("resource")] + Resource, + /// + /// Applies to a line range within a single file. + /// + [WireValue("range")] + Range, +} + +/// +/// Discriminant for {@link ResourceChange.type}. +/// +[JsonConverter(typeof(WireEnumConverter))] +public enum ResourceChangeType +{ + [WireValue("added")] + Added, + [WireValue("updated")] + Updated, + [WireValue("deleted")] + Deleted, +} + +// ─── Classes ────────────────────────────────────────────────────────── + +/// +/// An optionally-sized icon that can be displayed in a user interface. +/// +public sealed class Icon +{ + /// + /// A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + /// `data:` URI with Base64-encoded image data. + /// + /// Consumers SHOULD take steps to ensure URLs serving icons are from the + /// same domain as the client/server or a trusted domain. + /// + /// Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + /// executable JavaScript. + /// + [JsonPropertyName("src")] + public string Src { get; set; } = ""; + + /// + /// Optional MIME type override if the source MIME type is missing or generic. + /// For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } + + /// + /// Optional array of strings that specify sizes at which the icon can be used. + /// Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + /// + /// If not provided, the client should assume that the icon can be used at any size. + /// + [JsonPropertyName("sizes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Sizes { get; set; } + + /// + /// Optional specifier for the theme this icon is designed for. `"light"` indicates + /// the icon is designed to be used with a light background, and `"dark"` indicates + /// the icon is designed to be used with a dark background. + /// + /// If not provided, the client should assume the icon can be used with any theme. + /// + [JsonPropertyName("theme")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Theme { get; set; } +} + +/// +/// Describes a protected resource's authentication requirements using +/// [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (OAuth 2.0 +/// Protected Resource Metadata) semantics. +/// +/// Field names use snake_case to match the RFC 9728 JSON format. +/// +public sealed class ProtectedResourceMetadata +{ + /// + /// REQUIRED. The protected resource's resource identifier, a URL using the + /// `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// OPTIONAL. Human-readable name of the protected resource. + /// + [JsonPropertyName("resource_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceName { get; set; } + + /// + /// OPTIONAL. JSON array of OAuth authorization server identifier URLs. + /// + [JsonPropertyName("authorization_servers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AuthorizationServers { get; set; } + + /// + /// OPTIONAL. URL of the protected resource's JWK Set document. + /// + [JsonPropertyName("jwks_uri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? JwksUri { get; set; } + + /// + /// RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. + /// + [JsonPropertyName("scopes_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ScopesSupported { get; set; } + + /// + /// OPTIONAL. JSON array of Bearer Token presentation methods supported. + /// + [JsonPropertyName("bearer_methods_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? BearerMethodsSupported { get; set; } + + /// + /// OPTIONAL. JSON array of JWS signing algorithms supported. + /// + [JsonPropertyName("resource_signing_alg_values_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ResourceSigningAlgValuesSupported { get; set; } + + /// + /// OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. + /// + [JsonPropertyName("resource_encryption_alg_values_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ResourceEncryptionAlgValuesSupported { get; set; } + + /// + /// OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. + /// + [JsonPropertyName("resource_encryption_enc_values_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ResourceEncryptionEncValuesSupported { get; set; } + + /// + /// OPTIONAL. URL of human-readable documentation for the resource. + /// + [JsonPropertyName("resource_documentation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceDocumentation { get; set; } + + /// + /// OPTIONAL. URL of the resource's data-usage policy. + /// + [JsonPropertyName("resource_policy_uri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourcePolicyUri { get; set; } + + /// + /// OPTIONAL. URL of the resource's terms of service. + /// + [JsonPropertyName("resource_tos_uri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceTosUri { get; set; } + + /// + /// AHP extension. Whether authentication is required for this resource. + /// + /// - `true` (default) — the agent cannot be used without a valid token. + /// The server SHOULD return `AuthRequired` (`-32007`) if the client + /// attempts to use the agent without authenticating. + /// - `false` — the agent works without authentication but MAY offer + /// enhanced capabilities when a token is provided. + /// + /// Clients SHOULD treat an absent field the same as `true`. + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; set; } +} + +/// +/// Global state shared with every client subscribed to `ahp-root://`. +/// +public sealed class RootState +{ + /// + /// Available agent backends and their models + /// + [JsonPropertyName("agents")] + public List Agents { get; set; } = null!; + + /// + /// Number of active (non-disposed) sessions on the server + /// + [JsonPropertyName("activeSessions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ActiveSessions { get; set; } + + /// + /// Known terminals on the server. Subscribe to individual terminal URIs for full state. + /// + [JsonPropertyName("terminals")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Terminals { get; set; } + + /// + /// Agent host configuration schema and current values + /// + [JsonPropertyName("config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public RootConfigState? Config { get; set; } + + /// + /// Additional implementation-defined metadata about the agent host itself. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// Live agent-host configuration metadata. +/// +/// The schema describes the available configuration properties and the values +/// contain the current value for each resolved property. +/// +public sealed class RootConfigState +{ + /// + /// JSON Schema describing available configuration properties + /// + [JsonPropertyName("schema")] + public required ConfigSchema Schema { get; set; } + + /// + /// Current configuration values + /// + [JsonPropertyName("values")] + public Dictionary Values { get; set; } = null!; +} + +public sealed class AgentInfo +{ + /// + /// Agent provider ID (e.g. `'copilot'`) + /// + [JsonPropertyName("provider")] + public string Provider { get; set; } = ""; + + /// + /// Human-readable name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Description string + /// + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + /// + /// Available models for this agent + /// + [JsonPropertyName("models")] + public List Models { get; set; } = null!; + + /// + /// Protected resources this agent requires authentication for. + /// + /// Each entry describes an OAuth 2.0 protected resource using + /// [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. + /// Clients should obtain tokens from the declared `authorization_servers` + /// and push them via the `authenticate` command before creating sessions + /// with this agent. + /// + [JsonPropertyName("protectedResources")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ProtectedResources { get; set; } + + /// + /// Customizations associated with this agent. + /// + /// Either container customizations — + /// {@link PluginCustomization | `PluginCustomization`} entries the agent + /// bundles, plus {@link DirectoryCustomization | `DirectoryCustomization`} + /// entries it watches in any workspace it's used with — or top-level + /// {@link McpServerCustomization | `McpServerCustomization`} entries + /// the agent host declares directly. When a session is created with + /// this agent, these entries are augmented (e.g. directory URIs are + /// resolved against the workspace, children are parsed) and propagated + /// into the session's `customizations` list. + /// + [JsonPropertyName("customizations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Customizations { get; set; } +} + +public sealed class SessionModelInfo +{ + /// + /// Model identifier + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Provider this model belongs to + /// + [JsonPropertyName("provider")] + public string Provider { get; set; } = ""; + + /// + /// Human-readable model name + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Maximum context window size + /// + [JsonPropertyName("maxContextWindow")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? MaxContextWindow { get; set; } + + /// + /// Whether the model supports vision + /// + [JsonPropertyName("supportsVision")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SupportsVision { get; set; } + + /// + /// Policy configuration state + /// + [JsonPropertyName("policyState")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PolicyState? PolicyState { get; set; } + + /// + /// Configuration schema describing model-specific options (e.g. thinking + /// level). Clients present this as a form and pass the resolved values in + /// {@link ModelSelection.config} when creating or changing sessions. + /// + [JsonPropertyName("configSchema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigSchema? ConfigSchema { get; set; } + + /// + /// Additional provider-specific metadata for this model. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `pricing` key may carry model pricing metadata. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// A model selection: the chosen model ID together with any model-specific +/// configuration values whose keys correspond to the model's +/// {@link SessionModelInfo.configSchema}. +/// +public sealed class ModelSelection +{ + /// + /// Model identifier + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Model-specific configuration values + /// + [JsonPropertyName("config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; set; } +} + +/// +/// A selected custom agent for a session. +/// +/// The `uri` identifies a specific custom agent (matching an +/// {@link AgentCustomization.uri | `AgentCustomization.uri`} exposed via +/// the session's effective customizations). Consumers resolve the agent's +/// display name by looking up `uri` in the session's customization tree. +/// +/// A session with no `agent` selected uses the provider's default behavior. +/// +public sealed class AgentSelection +{ + /// + /// Stable agent URI (matches an {@link AgentCustomization.uri}). + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; +} + +/// +/// A JSON Schema-compatible property descriptor with display extensions. +/// +/// Standard JSON Schema fields (`type`, `title`, `description`, `default`, +/// `enum`) allow validators to process the schema. Display extensions +/// (`enumLabels`, `enumDescriptions`) are parallel arrays that provide UI +/// metadata for each `enum` value. +/// +/// This is the generic base type. See {@link SessionConfigPropertySchema} for +/// session-specific extensions. +/// +public sealed class ConfigPropertySchema +{ + /// + /// JSON Schema: property type + /// + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// + /// JSON Schema: human-readable label for the property + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + /// + /// JSON Schema: description / tooltip + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// JSON Schema: default value + /// + [JsonPropertyName("default")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Default { get; set; } + + /// + /// JSON Schema: allowed values (typically used with `string` type) + /// + [JsonPropertyName("enum")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Enum { get; set; } + + /// + /// Display extension: human-readable label per enum value (parallel array) + /// + [JsonPropertyName("enumLabels")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumLabels { get; set; } + + /// + /// Display extension: description per enum value (parallel array) + /// + [JsonPropertyName("enumDescriptions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumDescriptions { get; set; } + + /// + /// JSON Schema: when `true`, the property is displayed but cannot be modified by the user + /// + [JsonPropertyName("readOnly")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReadOnly { get; set; } + + /// + /// JSON Schema: schema for array items (used when `type` is `'array'`) + /// + [JsonPropertyName("items")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigPropertySchema? Items { get; set; } + + /// + /// JSON Schema: property descriptors for object properties (used when `type` is `'object'`) + /// + [JsonPropertyName("properties")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Properties { get; set; } + + /// + /// JSON Schema: list of required property ids (used when `type` is `'object'`) + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; set; } +} + +/// +/// A JSON Schema object describing available configuration properties. +/// +/// This is the generic base type. See {@link SessionConfigSchema} for +/// session-specific usage. +/// +public sealed class ConfigSchema +{ + /// + /// JSON Schema: always `'object'` + /// + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// + /// JSON Schema: property descriptors keyed by property id + /// + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = null!; + + /// + /// JSON Schema: list of required property ids + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; set; } +} + +/// +/// A message queued for future delivery to the agent. +/// +/// Steering messages are injected into the current turn mid-flight. +/// Queued messages are automatically started as new turns after the +/// current turn naturally finishes. +/// +public sealed class PendingMessage +{ + /// + /// Unique identifier for this pending message + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// The message that will start the next turn + /// + [JsonPropertyName("message")] + public required Message Message { get; set; } +} + +/// +/// Full state for a single session, loaded when a client subscribes to the session's URI. +/// +public sealed class SessionState +{ + /// + /// Lightweight session metadata + /// + [JsonPropertyName("summary")] + public required SessionSummary Summary { get; set; } + + /// + /// Session initialization state + /// + [JsonPropertyName("lifecycle")] + public SessionLifecycle Lifecycle { get; set; } + + /// + /// Error details if creation failed + /// + [JsonPropertyName("creationError")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? CreationError { get; set; } + + /// + /// Tools provided by the server (agent host) for this session + /// + [JsonPropertyName("serverTools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ServerTools { get; set; } + + /// + /// The client currently providing tools and interactive capabilities to this session + /// + [JsonPropertyName("activeClient")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionActiveClient? ActiveClient { get; set; } + + /// + /// Completed turns + /// + [JsonPropertyName("turns")] + public List Turns { get; set; } = null!; + + /// + /// Currently in-progress turn + /// + [JsonPropertyName("activeTurn")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ActiveTurn? ActiveTurn { get; set; } + + /// + /// Message to inject into the current turn at a convenient point + /// + [JsonPropertyName("steeringMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PendingMessage? SteeringMessage { get; set; } + + /// + /// Messages to send automatically as new turns after the current turn finishes + /// + [JsonPropertyName("queuedMessages")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? QueuedMessages { get; set; } + + /// + /// Requests for user input that are currently blocking or informing session progress + /// + [JsonPropertyName("inputRequests")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? InputRequests { get; set; } + + /// + /// Session configuration schema and current values + /// + [JsonPropertyName("config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionConfigState? Config { get; set; } + + /// + /// Top-level customizations active in this session. + /// + /// Always one of the {@link Customization} variants: + /// + /// - Container customizations ({@link PluginCustomization}, + /// {@link DirectoryCustomization}) whose children — agents, skills, + /// prompts, rules, hooks, MCP servers — live in each container's + /// {@link ContainerCustomizationBase.children | `children`} array. + /// - Top-level {@link McpServerCustomization} entries the host + /// surfaces directly (for example a globally-configured MCP server + /// that isn't bundled in a plugin or directory). MCP servers may + /// also appear as children of a container. + /// + /// Client-published plugins arrive via + /// {@link SessionActiveClient.customizations | `activeClient.customizations`} + /// and the host propagates them into this list (typically with the + /// container's `clientId` set and `children` populated). Clients + /// publish in container shape only; bare MCP servers at the top level + /// are server-originated. + /// + [JsonPropertyName("customizations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Customizations { get; set; } + + /// + /// Catalogue of changesets the server can produce for this session. Each + /// entry advertises a subscribable view of file changes (uncommitted, + /// session-wide, per-turn, etc.) and the URI template the client expands + /// before subscribing. See {@link Changeset} for the full shape and + /// {@link /guide/changesets | Changesets} for an overview of the model. + /// + [JsonPropertyName("changesets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Changesets { get; set; } + + /// + /// Additional provider-specific metadata for this session. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI. + /// For example, a `git` key may provide extra git metadata about the session's + /// workingDirectory. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// The client currently providing tools and interactive capabilities to a session. +/// +/// Only one client may be active per session at a time. The server SHOULD +/// automatically unset the active client if that client disconnects. +/// +public sealed class SessionActiveClient +{ + /// + /// Client identifier (matches `clientId` from `initialize`) + /// + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; + + /// + /// Human-readable client name (e.g. `"VS Code"`) + /// + [JsonPropertyName("displayName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayName { get; set; } + + /// + /// Tools this client provides to the session + /// + [JsonPropertyName("tools")] + public List Tools { get; set; } = null!; + + /// + /// Plugin customizations this client contributes to the session. + /// + /// Clients publish in [Open Plugins](https://open-plugins.com/) format + /// — i.e. always container-shaped plugins. They MAY synthesize virtual + /// plugins in memory and rely on the host to expand them into concrete + /// children inside {@link SessionState.customizations}. + /// + [JsonPropertyName("customizations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Customizations { get; set; } +} + +public sealed class SessionSummary +{ + /// + /// Session URI + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Agent provider ID + /// + [JsonPropertyName("provider")] + public string Provider { get; set; } = ""; + + /// + /// Session title + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + /// + /// Current session status + /// + [JsonPropertyName("status")] + public SessionStatus Status { get; set; } + + /// + /// Human-readable description of what the session is currently doing + /// + [JsonPropertyName("activity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; set; } + + /// + /// Creation timestamp + /// + [JsonPropertyName("createdAt")] + public long CreatedAt { get; set; } + + /// + /// Last modification timestamp + /// + [JsonPropertyName("modifiedAt")] + public long ModifiedAt { get; set; } + + /// + /// Server-owned project for this session + /// + [JsonPropertyName("project")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProjectInfo? Project { get; set; } + + /// + /// Currently selected model + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; set; } + + /// + /// Currently selected custom agent. + /// + /// Absent (`undefined`) means no custom agent is selected for this session + /// — the session uses the provider's default behavior. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } + + /// + /// The working directory URI for this session + /// + [JsonPropertyName("workingDirectory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } + + /// + /// Aggregate summary of file changes associated with this session. Servers + /// may populate this to give clients a quick at-a-glance view of the + /// session's footprint (e.g., for list rendering) without requiring the + /// client to subscribe to a changeset. + /// + [JsonPropertyName("changes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesSummary? Changes { get; set; } + + /// + /// Lightweight summary of this session's inline annotations channel + /// (`ahp-session:/<uuid>/annotations`). Surfaced so badge UI can render + /// annotation / entry counts without subscribing. Absent when the session + /// does not expose an annotations channel. + /// + [JsonPropertyName("annotations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AnnotationsSummary? Annotations { get; set; } +} + +/// +/// Aggregate counts describing the file changes associated with a session. +/// +/// All fields are optional so servers can populate only the metrics they +/// cheaply have available. +/// +public sealed class ChangesSummary +{ + /// + /// Total number of inserted lines across all changed files. + /// + [JsonPropertyName("additions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Additions { get; set; } + + /// + /// Total number of deleted lines across all changed files. + /// + [JsonPropertyName("deletions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Deletions { get; set; } + + /// + /// Number of files that have changes. + /// + [JsonPropertyName("files")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Files { get; set; } +} + +/// +/// Server-owned project metadata for a session. +/// +public sealed class ProjectInfo +{ + /// + /// Project URI + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable project name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; +} + +/// +/// A session configuration property descriptor. +/// +/// Extends the generic {@link ConfigPropertySchema} with session-specific +/// display extensions. +/// +public sealed class SessionConfigPropertySchema +{ + /// + /// JSON Schema: property type + /// + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// + /// JSON Schema: human-readable label for the property + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + /// + /// JSON Schema: description / tooltip + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// JSON Schema: default value + /// + [JsonPropertyName("default")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Default { get; set; } + + /// + /// JSON Schema: allowed values (typically used with `string` type) + /// + [JsonPropertyName("enum")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Enum { get; set; } + + /// + /// Display extension: human-readable label per enum value (parallel array) + /// + [JsonPropertyName("enumLabels")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumLabels { get; set; } + + /// + /// Display extension: description per enum value (parallel array) + /// + [JsonPropertyName("enumDescriptions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumDescriptions { get; set; } + + /// + /// JSON Schema: when `true`, the property is displayed but cannot be modified by the user + /// + [JsonPropertyName("readOnly")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReadOnly { get; set; } + + /// + /// JSON Schema: schema for array items (used when `type` is `'array'`) + /// + [JsonPropertyName("items")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigPropertySchema? Items { get; set; } + + /// + /// JSON Schema: property descriptors for object properties (used when `type` is `'object'`) + /// + [JsonPropertyName("properties")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Properties { get; set; } + + /// + /// JSON Schema: list of required property ids (used when `type` is `'object'`) + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; set; } + + /// + /// Display extension: when `true`, the full set of allowed values is too large + /// to enumerate statically. The client SHOULD use `sessionConfigCompletions` + /// to fetch matching values based on user input. Any values in `enum` are + /// seed/recent values for initial display. + /// + [JsonPropertyName("enumDynamic")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? EnumDynamic { get; set; } + + /// + /// When `true`, the user may change this property after session creation + /// + [JsonPropertyName("sessionMutable")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SessionMutable { get; set; } +} + +/// +/// A JSON Schema object describing available session configuration metadata. +/// +public sealed class SessionConfigSchema +{ + /// + /// JSON Schema: always `'object'` + /// + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// + /// JSON Schema: property descriptors keyed by property id + /// + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = null!; + + /// + /// JSON Schema: list of required property ids + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; set; } +} + +/// +/// Live session configuration metadata. +/// +/// The schema describes the available configuration properties and the values +/// contain the current value for each resolved property. +/// +public sealed class SessionConfigState +{ + /// + /// JSON Schema describing available configuration properties + /// + [JsonPropertyName("schema")] + public required SessionConfigSchema Schema { get; set; } + + /// + /// Current configuration values + /// + [JsonPropertyName("values")] + public Dictionary Values { get; set; } = null!; +} + +/// +/// A completed request/response cycle. +/// +public sealed class Turn +{ + /// + /// Turn identifier + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// The message that initiated the turn + /// + [JsonPropertyName("message")] + public required Message Message { get; set; } + + /// + /// All response content in stream order: text, tool calls, reasoning, and content refs. + /// + /// Consumers should derive display text by concatenating markdown parts, + /// and find tool calls by filtering for `ToolCall` parts. + /// + [JsonPropertyName("responseParts")] + public List ResponseParts { get; set; } = null!; + + /// + /// Token usage info + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UsageInfo? Usage { get; set; } + + /// + /// How the turn ended + /// + [JsonPropertyName("state")] + public TurnState State { get; set; } + + /// + /// Error details if state is `'error'` + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; set; } +} + +/// +/// An in-progress turn — the assistant is actively streaming. +/// +public sealed class ActiveTurn +{ + /// + /// Turn identifier + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// The message that initiated the turn + /// + [JsonPropertyName("message")] + public required Message Message { get; set; } + + /// + /// All response content in stream order: text, tool calls, reasoning, and content refs. + /// + /// Tool call parts include `pendingPermissions` when permissions are awaiting user approval. + /// + [JsonPropertyName("responseParts")] + public List ResponseParts { get; set; } = null!; + + /// + /// Token usage info + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UsageInfo? Usage { get; set; } +} + +/// +/// A message that initiates or steers a turn. Messages can originate from the +/// user or be system-generated (see {@link MessageKind}). +/// +/// Attachments MAY be referenced inside {@link Message.text} via their +/// {@link MessageAttachmentBase.range} field. Attachments without a range are +/// still associated with the message but do not correspond to a specific span +/// in the text. +/// +public sealed class Message +{ + /// + /// Message text + /// + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + /// + /// The origin of the message + /// + [JsonPropertyName("origin")] + public JsonElement Origin { get; set; } + + /// + /// File/selection attachments + /// + [JsonPropertyName("attachments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Attachments { get; set; } + + /// + /// Additional provider-specific metadata for this message. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry context that does not fit any other + /// field. Mirrors the MCP `_meta` convention. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// A choice in a select-style question. +/// +public sealed class SessionInputOption +{ + /// + /// Stable option identifier; for MCP enum values this is the enum string + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Display label + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// Optional secondary text + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// Whether this option is the recommended/default choice + /// + [JsonPropertyName("recommended")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recommended { get; set; } +} + +/// +/// Value captured for one answer. +/// +public sealed class SessionInputTextAnswerValue +{ + [JsonPropertyName("kind")] + public SessionInputAnswerValueKind Kind { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } = ""; +} + +public sealed class SessionInputNumberAnswerValue +{ + [JsonPropertyName("kind")] + public SessionInputAnswerValueKind Kind { get; set; } + + [JsonPropertyName("value")] + public double Value { get; set; } +} + +public sealed class SessionInputBooleanAnswerValue +{ + [JsonPropertyName("kind")] + public SessionInputAnswerValueKind Kind { get; set; } + + [JsonPropertyName("value")] + public bool Value { get; set; } +} + +public sealed class SessionInputSelectedAnswerValue +{ + [JsonPropertyName("kind")] + public SessionInputAnswerValueKind Kind { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } = ""; + + /// + /// Free-form text entered instead of selecting an option + /// + [JsonPropertyName("freeformValues")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; set; } +} + +public sealed class SessionInputSelectedManyAnswerValue +{ + [JsonPropertyName("kind")] + public SessionInputAnswerValueKind Kind { get; set; } + + [JsonPropertyName("value")] + public List Value { get; set; } = null!; + + /// + /// Free-form text entered in addition to selected options + /// + [JsonPropertyName("freeformValues")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; set; } +} + +public sealed class SessionInputAnswered +{ + /// + /// Answer state + /// + [JsonPropertyName("state")] + public SessionInputAnswerState State { get; set; } + + /// + /// Answer value + /// + [JsonPropertyName("value")] + public required SessionInputAnswerValue Value { get; set; } +} + +public sealed class SessionInputSkipped +{ + /// + /// Answer state + /// + [JsonPropertyName("state")] + public SessionInputAnswerState State { get; set; } + + /// + /// Free-form reason or value captured while skipping, if any + /// + [JsonPropertyName("freeformValues")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; set; } +} + +/// +/// Text question within a session input request. +/// +public sealed class SessionInputTextQuestion +{ + /// + /// Stable question identifier used as the key in `answers` + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Short display title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Prompt shown to the user + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + /// + /// Whether the user must answer this question to accept the request + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; set; } + + [JsonPropertyName("kind")] + public SessionInputQuestionKind Kind { get; set; } + + /// + /// Format hint for text questions, such as `email`, `uri`, `date`, or `date-time` + /// + [JsonPropertyName("format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Format { get; set; } + + /// + /// Minimum string length + /// + [JsonPropertyName("min")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; set; } + + /// + /// Maximum string length + /// + [JsonPropertyName("max")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; set; } + + /// + /// Default text + /// + [JsonPropertyName("defaultValue")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultValue { get; set; } +} + +/// +/// Numeric question within a session input request. +/// +public sealed class SessionInputNumberQuestion +{ + /// + /// Stable question identifier used as the key in `answers` + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Short display title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Prompt shown to the user + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + /// + /// Whether the user must answer this question to accept the request + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; set; } + + [JsonPropertyName("kind")] + public SessionInputQuestionKind Kind { get; set; } + + /// + /// Minimum value + /// + [JsonPropertyName("min")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Min { get; set; } + + /// + /// Maximum value + /// + [JsonPropertyName("max")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Max { get; set; } + + /// + /// Default numeric value + /// + [JsonPropertyName("defaultValue")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? DefaultValue { get; set; } +} + +/// +/// Boolean question within a session input request. +/// +public sealed class SessionInputBooleanQuestion +{ + /// + /// Stable question identifier used as the key in `answers` + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Short display title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Prompt shown to the user + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + /// + /// Whether the user must answer this question to accept the request + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; set; } + + [JsonPropertyName("kind")] + public SessionInputQuestionKind Kind { get; set; } + + /// + /// Default boolean value + /// + [JsonPropertyName("defaultValue")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DefaultValue { get; set; } +} + +/// +/// Single-select question within a session input request. +/// +public sealed class SessionInputSingleSelectQuestion +{ + /// + /// Stable question identifier used as the key in `answers` + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Short display title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Prompt shown to the user + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + /// + /// Whether the user must answer this question to accept the request + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; set; } + + [JsonPropertyName("kind")] + public SessionInputQuestionKind Kind { get; set; } + + /// + /// Options the user may select from + /// + [JsonPropertyName("options")] + public List Options { get; set; } = null!; + + /// + /// Whether the user may enter text instead of selecting an option + /// + [JsonPropertyName("allowFreeformInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; set; } +} + +/// +/// Multi-select question within a session input request. +/// +public sealed class SessionInputMultiSelectQuestion +{ + /// + /// Stable question identifier used as the key in `answers` + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Short display title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Prompt shown to the user + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + /// + /// Whether the user must answer this question to accept the request + /// + [JsonPropertyName("required")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; set; } + + [JsonPropertyName("kind")] + public SessionInputQuestionKind Kind { get; set; } + + /// + /// Options the user may select from + /// + [JsonPropertyName("options")] + public List Options { get; set; } = null!; + + /// + /// Whether the user may enter text in addition to selecting options + /// + [JsonPropertyName("allowFreeformInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; set; } + + /// + /// Minimum selected item count + /// + [JsonPropertyName("min")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; set; } + + /// + /// Maximum selected item count + /// + [JsonPropertyName("max")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; set; } +} + +/// +/// A live request for user input. +/// +/// The server creates or replaces requests with `session/inputRequested`. +/// Clients sync drafts with `session/inputAnswerChanged` and complete requests +/// with `session/inputCompleted`. +/// +public sealed class SessionInputRequest +{ + /// + /// Stable request identifier + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Display message for the request as a whole + /// + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; set; } + + /// + /// URL the user should review or open, for URL-style elicitations + /// + [JsonPropertyName("url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Url { get; set; } + + /// + /// Ordered questions to ask the user + /// + [JsonPropertyName("questions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Questions { get; set; } + + /// + /// Current draft or submitted answers, keyed by question ID + /// + [JsonPropertyName("answers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; set; } +} + +/// +/// A zero-based position within a textual document. +/// +public sealed class TextPosition +{ + /// + /// Zero-based line number. + /// + [JsonPropertyName("line")] + public long Line { get; set; } + + /// + /// Zero-based character offset within the line. + /// + [JsonPropertyName("character")] + public long Character { get; set; } +} + +/// +/// A range within a textual document. +/// +public sealed class TextRange +{ + /// + /// Start position of the range. + /// + [JsonPropertyName("start")] + public required TextPosition Start { get; set; } + + /// + /// End position of the range. + /// + [JsonPropertyName("end")] + public required TextPosition End { get; set; } +} + +/// +/// A selection within a textual resource. +/// +/// This is only meaningful for textual resources. Binary resources may still +/// use resource or embedded resource attachments, but they should not use this +/// text selection field. +/// +public sealed class TextSelection +{ + /// + /// The range covered by the selection. + /// + [JsonPropertyName("range")] + public required TextRange Range { get; set; } +} + +/// +/// A simple, opaque attachment whose model representation is described by +/// the producer. +/// +public sealed class SimpleMessageAttachment +{ + /// + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + /// + [JsonPropertyName("displayKind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; set; } + + /// + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Discriminant + /// + [JsonPropertyName("type")] + public MessageAttachmentKind Type { get; set; } + + /// + /// Representation of the attachment as it should be shown to the model. + /// + /// If the attachment was produced by the client, this property MUST be + /// defined so the agent host can correctly interpret the attachment. This + /// property MAY be omitted when the attachment originated from a + /// `completions` response. + /// + [JsonPropertyName("modelRepresentation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ModelRepresentation { get; set; } +} + +/// +/// An attachment whose data is embedded inline as a base64 string. +/// +/// Use this for small binary payloads (e.g. a pasted image) that should be +/// delivered with the user message itself rather than fetched separately. +/// +public sealed class MessageEmbeddedResourceAttachment +{ + /// + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + /// + [JsonPropertyName("displayKind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; set; } + + /// + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Discriminant + /// + [JsonPropertyName("type")] + public MessageAttachmentKind Type { get; set; } + + /// + /// Base64-encoded binary data + /// + [JsonPropertyName("data")] + public string Data { get; set; } = ""; + + /// + /// Content MIME type (e.g. `"image/png"`, `"application/pdf"`) + /// + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = ""; + + /// + /// Optional selection within the attached textual resource. + /// + /// Only meaningful for textual resources. + /// + [JsonPropertyName("selection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextSelection? Selection { get; set; } +} + +/// +/// An attachment that references a resource by URI. The content is not +/// delivered inline; consumers can fetch it via `resourceRead` when needed. +/// +public sealed class MessageResourceAttachment +{ + /// + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + /// + [JsonPropertyName("displayKind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; set; } + + /// + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Content URI + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Approximate size in bytes + /// + [JsonPropertyName("sizeHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; set; } + + /// + /// Content MIME type + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } + + /// + /// Discriminant + /// + [JsonPropertyName("type")] + public MessageAttachmentKind Type { get; set; } + + /// + /// Optional selection within the referenced textual resource. + /// + /// Only meaningful for textual resources. + /// + [JsonPropertyName("selection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextSelection? Selection { get; set; } +} + +/// +/// An attachment that references annotations on a session's annotations +/// channel (see {@link AnnotationsState}). +/// +/// When {@link annotationIds} is omitted the attachment references every +/// annotation on the channel; when present it references only the listed +/// {@link Annotation.id | annotation ids}. +/// +public sealed class MessageAnnotationsAttachment +{ + /// + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + /// + [JsonPropertyName("displayKind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; set; } + + /// + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Discriminant + /// + [JsonPropertyName("type")] + public MessageAttachmentKind Type { get; set; } + + /// + /// The annotations channel URI (typically `ahp-session:/<uuid>/annotations`). + /// Matches {@link AnnotationsSummary.resource}. + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Specific {@link Annotation.id | annotation ids} to reference. When + /// omitted, the attachment references all annotations on the channel. + /// + [JsonPropertyName("annotationIds")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AnnotationIds { get; set; } +} + +public sealed class MarkdownResponsePart +{ + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public ResponsePartKind Kind { get; set; } + + /// + /// Part identifier, used by `session/delta` to target this part for content appends + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Markdown content + /// + [JsonPropertyName("content")] + public string Content { get; set; } = ""; +} + +/// +/// A reference to large content stored outside the state tree. +/// +public sealed class ContentRef +{ + /// + /// Content URI + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Approximate size in bytes + /// + [JsonPropertyName("sizeHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; set; } + + /// + /// Content MIME type + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } +} + +/// +/// A content part that's a reference to large content stored outside the state tree. +/// +public sealed class ResourceResponsePart +{ + /// + /// Content URI + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Approximate size in bytes + /// + [JsonPropertyName("sizeHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; set; } + + /// + /// Content MIME type + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } + + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public ResponsePartKind Kind { get; set; } +} + +/// +/// A tool call represented as a response part. +/// +/// Tool calls are part of the response stream, interleaved with text and +/// reasoning. The `toolCall.toolCallId` serves as the part identifier for +/// actions that target this part. +/// +public sealed class ToolCallResponsePart +{ + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public ResponsePartKind Kind { get; set; } + + /// + /// Full tool call lifecycle state + /// + [JsonPropertyName("toolCall")] + public required ToolCallState ToolCall { get; set; } +} + +/// +/// Reasoning/thinking content from the model. +/// +public sealed class ReasoningResponsePart +{ + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public ResponsePartKind Kind { get; set; } + + /// + /// Part identifier, used by `session/reasoning` to target this part for content appends + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Accumulated reasoning text + /// + [JsonPropertyName("content")] + public string Content { get; set; } = ""; +} + +/// +/// A system notification surfaced as part of the response stream. +/// +/// System notifications are messages authored by the agent harness +/// that need to be visible to both the agent (for situational awareness) and +/// the user (for transcript continuity). Examples include "background subagent +/// X completed" or "task Y was cancelled". +/// +public sealed class SystemNotificationResponsePart +{ + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public ResponsePartKind Kind { get; set; } + + /// + /// The text of the system notification + /// + [JsonPropertyName("content")] + public StringOrMarkdown Content { get; set; } = new(); +} + +/// +/// Tool execution result details, available after execution completes. +/// +public sealed class ToolCallResult +{ + /// + /// Whether the tool succeeded + /// + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// + /// Past-tense description of what the tool did + /// + [JsonPropertyName("pastTenseMessage")] + public StringOrMarkdown PastTenseMessage { get; set; } = new(); + + /// + /// Unstructured result content blocks. + /// + /// This mirrors the `content` field of MCP `CallToolResult`. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; set; } + + /// + /// Optional structured result object. + /// + /// This mirrors the `structuredContent` field of MCP `CallToolResult`. + /// + [JsonPropertyName("structuredContent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? StructuredContent { get; set; } + + /// + /// Error details if the tool failed + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Error { get; set; } +} + +/// +/// A confirmation option that the server offers for a tool call awaiting +/// approval. Allows richer choices beyond simple approve/deny — for example, +/// "Approve in this Session" or "Deny with reason." +/// +public sealed class ConfirmationOption +{ + /// + /// Unique identifier for the option, returned in the confirmed action + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Human-readable label displayed to the user + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// Whether this option represents an approval or denial + /// + [JsonPropertyName("kind")] + public ConfirmationOptionKind Kind { get; set; } + + /// + /// Logical group number for visual categorisation. + /// + /// Clients SHOULD display options in the order they are defined and MAY + /// use differing group numbers to insert dividers between logical clusters + /// of options. + /// + [JsonPropertyName("group")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Group { get; set; } +} + +/// +/// LM is streaming the tool call parameters. +/// +public sealed class ToolCallStreamingState +{ + /// + /// Unique tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) + /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination + /// with the {@link contributor} to serve MCP Apps. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("status")] + public ToolCallStatus Status { get; set; } + + /// + /// Partial parameters accumulated so far + /// + [JsonPropertyName("partialInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PartialInput { get; set; } + + /// + /// Progress message shown while parameters are streaming + /// + [JsonPropertyName("invocationMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? InvocationMessage { get; set; } +} + +/// +/// Parameters are complete, or a running tool requires re-confirmation +/// (e.g. a mid-execution permission check). +/// +public sealed class ToolCallPendingConfirmationState +{ + /// + /// Unique tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) + /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination + /// with the {@link contributor} to serve MCP Apps. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Message describing what the tool will do + /// + [JsonPropertyName("invocationMessage")] + public StringOrMarkdown InvocationMessage { get; set; } = new(); + + /// + /// Raw tool input + /// + [JsonPropertyName("toolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + [JsonPropertyName("status")] + public ToolCallStatus Status { get; set; } + + /// + /// Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) + /// + [JsonPropertyName("confirmationTitle")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ConfirmationTitle { get; set; } + + /// + /// File edits that this tool call will perform, for preview before confirmation + /// + [JsonPropertyName("edits")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Edits { get; set; } + + /// + /// Whether the agent host allows the client to edit the tool's input parameters before confirming + /// + [JsonPropertyName("editable")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Editable { get; set; } + + /// + /// Options the server offers for this confirmation. When present, the client + /// SHOULD render these instead of a plain approve/deny UI. Each option + /// belongs to a {@link ConfirmationOptionGroup} so the client can still + /// categorise the choices. + /// + [JsonPropertyName("options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; set; } +} + +/// +/// Tool is actively executing. +/// +public sealed class ToolCallRunningState +{ + /// + /// Unique tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) + /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination + /// with the {@link contributor} to serve MCP Apps. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Message describing what the tool will do + /// + [JsonPropertyName("invocationMessage")] + public StringOrMarkdown InvocationMessage { get; set; } = new(); + + /// + /// Raw tool input + /// + [JsonPropertyName("toolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + [JsonPropertyName("status")] + public ToolCallStatus Status { get; set; } + + /// + /// How the tool was confirmed for execution + /// + [JsonPropertyName("confirmed")] + public ToolCallConfirmationReason Confirmed { get; set; } + + /// + /// The confirmation option the user selected, if confirmation options were provided + /// + [JsonPropertyName("selectedOption")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; set; } + + /// + /// Partial content produced while the tool is still executing. + /// + /// For example, a terminal content block lets clients subscribe to live + /// output before the tool completes. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; set; } +} + +/// +/// Tool finished executing, waiting for client to approve the result. +/// +public sealed class ToolCallPendingResultConfirmationState +{ + /// + /// Unique tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) + /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination + /// with the {@link contributor} to serve MCP Apps. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Message describing what the tool will do + /// + [JsonPropertyName("invocationMessage")] + public StringOrMarkdown InvocationMessage { get; set; } = new(); + + /// + /// Raw tool input + /// + [JsonPropertyName("toolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + /// + /// Whether the tool succeeded + /// + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// + /// Past-tense description of what the tool did + /// + [JsonPropertyName("pastTenseMessage")] + public StringOrMarkdown PastTenseMessage { get; set; } = new(); + + /// + /// Unstructured result content blocks. + /// + /// This mirrors the `content` field of MCP `CallToolResult`. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; set; } + + /// + /// Optional structured result object. + /// + /// This mirrors the `structuredContent` field of MCP `CallToolResult`. + /// + [JsonPropertyName("structuredContent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? StructuredContent { get; set; } + + /// + /// Error details if the tool failed + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Error { get; set; } + + [JsonPropertyName("status")] + public ToolCallStatus Status { get; set; } + + /// + /// How the tool was confirmed for execution + /// + [JsonPropertyName("confirmed")] + public ToolCallConfirmationReason Confirmed { get; set; } + + /// + /// The confirmation option the user selected, if confirmation options were provided + /// + [JsonPropertyName("selectedOption")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; set; } +} + +/// +/// Tool completed successfully or with an error. +/// +public sealed class ToolCallCompletedState +{ + /// + /// Unique tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) + /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination + /// with the {@link contributor} to serve MCP Apps. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Message describing what the tool will do + /// + [JsonPropertyName("invocationMessage")] + public StringOrMarkdown InvocationMessage { get; set; } = new(); + + /// + /// Raw tool input + /// + [JsonPropertyName("toolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + /// + /// Whether the tool succeeded + /// + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// + /// Past-tense description of what the tool did + /// + [JsonPropertyName("pastTenseMessage")] + public StringOrMarkdown PastTenseMessage { get; set; } = new(); + + /// + /// Unstructured result content blocks. + /// + /// This mirrors the `content` field of MCP `CallToolResult`. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; set; } + + /// + /// Optional structured result object. + /// + /// This mirrors the `structuredContent` field of MCP `CallToolResult`. + /// + [JsonPropertyName("structuredContent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? StructuredContent { get; set; } + + /// + /// Error details if the tool failed + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Error { get; set; } + + [JsonPropertyName("status")] + public ToolCallStatus Status { get; set; } + + /// + /// How the tool was confirmed for execution + /// + [JsonPropertyName("confirmed")] + public ToolCallConfirmationReason Confirmed { get; set; } + + /// + /// The confirmation option the user selected, if confirmation options were provided + /// + [JsonPropertyName("selectedOption")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; set; } +} + +/// +/// Tool call was cancelled before execution. +/// +public sealed class ToolCallCancelledState +{ + /// + /// Unique tool call identifier + /// + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + /// + /// Internal tool name (for debugging/logging) + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + /// + /// Human-readable tool name + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Reference to the contributor of the tool being called. + /// + [JsonPropertyName("contributor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; set; } + + /// + /// Additional provider-specific metadata for this tool call. + /// + /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) + /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination + /// with the {@link contributor} to serve MCP Apps. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Message describing what the tool will do + /// + [JsonPropertyName("invocationMessage")] + public StringOrMarkdown InvocationMessage { get; set; } = new(); + + /// + /// Raw tool input + /// + [JsonPropertyName("toolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + [JsonPropertyName("status")] + public ToolCallStatus Status { get; set; } + + /// + /// Why the tool was cancelled + /// + [JsonPropertyName("reason")] + public ToolCallCancellationReason Reason { get; set; } + + /// + /// Optional message explaining the cancellation + /// + [JsonPropertyName("reasonMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; set; } + + /// + /// What the user suggested doing instead + /// + [JsonPropertyName("userSuggestion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; set; } + + /// + /// The confirmation option the user selected, if confirmation options were provided + /// + [JsonPropertyName("selectedOption")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; set; } +} + +/// +/// Describes a tool available in a session, provided by either the server or the active client. +/// +public sealed class ToolDefinition +{ + /// + /// Unique tool identifier + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Human-readable display name + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Description of what the tool does + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// JSON Schema defining the expected input parameters. + /// + /// Optional because client-provided tools may not have formal schemas. + /// Mirrors MCP `Tool.inputSchema`. + /// + [JsonPropertyName("inputSchema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? InputSchema { get; set; } + + /// + /// JSON Schema defining the structure of the tool's output. + /// + /// Mirrors MCP `Tool.outputSchema`. + /// + [JsonPropertyName("outputSchema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? OutputSchema { get; set; } + + /// + /// Behavioral hints about the tool. All properties are advisory. + /// + [JsonPropertyName("annotations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolAnnotations? Annotations { get; set; } + + /// + /// Additional provider-specific metadata. + /// + /// Mirrors the MCP `_meta` convention. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// Behavioral hints about a tool. All properties are advisory and not +/// guaranteed to faithfully describe tool behavior. +/// +/// Mirrors MCP `ToolAnnotations` from the Model Context Protocol specification. +/// +public sealed class ToolAnnotations +{ + /// + /// Alternate human-readable title + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// + /// Tool does not modify its environment (default: false) + /// + [JsonPropertyName("readOnlyHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReadOnlyHint { get; set; } + + /// + /// Tool may perform destructive updates (default: true) + /// + [JsonPropertyName("destructiveHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DestructiveHint { get; set; } + + /// + /// Repeated calls with the same arguments have no additional effect (default: false) + /// + [JsonPropertyName("idempotentHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IdempotentHint { get; set; } + + /// + /// Tool may interact with external entities (default: true) + /// + [JsonPropertyName("openWorldHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? OpenWorldHint { get; set; } +} + +/// +/// Text content in a tool result. +/// +/// Mirrors MCP `TextContent`. +/// +public sealed class ToolResultTextContent +{ + [JsonPropertyName("type")] + public ToolResultContentType Type { get; set; } + + /// + /// The text content + /// + [JsonPropertyName("text")] + public string Text { get; set; } = ""; +} + +/// +/// Base64-encoded binary content embedded in a tool result. +/// +/// Mirrors MCP `EmbeddedResource` for inline binary data. +/// +public sealed class ToolResultEmbeddedResourceContent +{ + [JsonPropertyName("type")] + public ToolResultContentType Type { get; set; } + + /// + /// Base64-encoded data + /// + [JsonPropertyName("data")] + public string Data { get; set; } = ""; + + /// + /// Content type (e.g. `"image/png"`, `"application/pdf"`) + /// + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = ""; +} + +/// +/// A reference to a resource stored outside the tool result. +/// +/// Wraps {@link ContentRef} for lazy-loading large results. +/// +public sealed class ToolResultResourceContent +{ + /// + /// Content URI + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Approximate size in bytes + /// + [JsonPropertyName("sizeHint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; set; } + + /// + /// Content MIME type + /// + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; set; } + + [JsonPropertyName("type")] + public ToolResultContentType Type { get; set; } +} + +/// +/// Describes a file modification performed by a tool. +/// +public sealed class ToolResultFileEditContent +{ + /// + /// The file state before the edit. Absent for file creations or for in-place file edits. + /// + [JsonPropertyName("before")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Before { get; set; } + + /// + /// The file state after the edit. Absent for file deletions. + /// + [JsonPropertyName("after")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? After { get; set; } + + /// + /// Optional diff display metadata + /// + [JsonPropertyName("diff")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Diff { get; set; } + + [JsonPropertyName("type")] + public ToolResultContentType Type { get; set; } +} + +/// +/// A reference to a terminal whose output is relevant to this tool result. +/// +/// Clients can subscribe to the terminal's URI to stream its output in real +/// time, providing live feedback while a tool is executing. +/// +public sealed class ToolResultTerminalContent +{ + [JsonPropertyName("type")] + public ToolResultContentType Type { get; set; } + + /// + /// Terminal URI (subscribable for full terminal state) + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Display title for the terminal content + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; +} + +/// +/// A reference to a subagent session spawned by a tool. +/// +/// Clients can subscribe to the subagent's session URI to stream its +/// progress in real time, including inner tool calls and responses. +/// +public sealed class ToolResultSubagentContent +{ + [JsonPropertyName("type")] + public ToolResultContentType Type { get; set; } + + /// + /// Subagent session URI (subscribable for full session state) + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Display title for the subagent + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + /// + /// Internal agent name + /// + [JsonPropertyName("agentName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AgentName { get; set; } + + /// + /// Human-readable description of the subagent's task + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } +} + +/// +/// Container is being loaded by the host. +/// +public sealed class CustomizationLoadingState +{ + [JsonPropertyName("kind")] + public CustomizationLoadStatus Kind { get; set; } +} + +/// +/// Container loaded successfully. +/// +public sealed class CustomizationLoadedState +{ + [JsonPropertyName("kind")] + public CustomizationLoadStatus Kind { get; set; } +} + +/// +/// Container partially loaded but has warnings. +/// +public sealed class CustomizationDegradedState +{ + [JsonPropertyName("kind")] + public CustomizationLoadStatus Kind { get; set; } + + /// + /// Human-readable description of the warning. + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; +} + +/// +/// Container failed to load. +/// +public sealed class CustomizationErrorState +{ + [JsonPropertyName("kind")] + public CustomizationLoadStatus Kind { get; set; } + + /// + /// Human-readable error message. + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; +} + +/// +/// An [Open Plugins](https://open-plugins.com/) plugin. +/// +public sealed class PluginCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Whether this container is currently enabled. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// `clientId` of the client that contributed this container. Absent for + /// server-originated entries. + /// + [JsonPropertyName("clientId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClientId { get; set; } + + /// + /// Host-reported load state. Absent means the host has not yet reported + /// a load state for this container. + /// + [JsonPropertyName("load")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomizationLoadState? Load { get; set; } + + /// + /// Children discovered inside this container. + /// + /// Absent means the host has not parsed this container yet. An empty + /// array means the host parsed the container and it contributes + /// nothing. + /// + [JsonPropertyName("children")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } +} + +/// +/// A {@link PluginCustomization} as published by a client. Extends the +/// server-facing shape with an opaque `nonce` so the host can detect when +/// the client's view of a plugin has changed and re-parse only as needed. +/// +/// Clients SHOULD include a `nonce`. Server-side fields like +/// {@link ContainerCustomizationBase.children | `children`} and +/// {@link ContainerCustomizationBase.load | `load`} are typically left +/// absent on publication and populated by the host when the resolved +/// plugin appears in {@link SessionState.customizations}. +/// +public sealed class ClientPluginCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Whether this container is currently enabled. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// `clientId` of the client that contributed this container. Absent for + /// server-originated entries. + /// + [JsonPropertyName("clientId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClientId { get; set; } + + /// + /// Host-reported load state. Absent means the host has not yet reported + /// a load state for this container. + /// + [JsonPropertyName("load")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomizationLoadState? Load { get; set; } + + /// + /// Children discovered inside this container. + /// + /// Absent means the host has not parsed this container yet. An empty + /// array means the host parsed the container and it contributes + /// nothing. + /// + [JsonPropertyName("children")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Opaque version token used by the host to detect changes. + /// + [JsonPropertyName("nonce")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Nonce { get; set; } +} + +/// +/// A directory the host watches for this session. +/// +/// Presence in the customization list signals that the host may discover +/// customizations from this directory. When `writable` is `true`, clients +/// MAY persist new customizations into the directory using +/// [`resourceWrite`](/reference/common#resourcewrite); the host will +/// then surface the resulting child via the customization actions. +/// +/// The directory may not yet exist on disk. +/// +public sealed class DirectoryCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Whether this container is currently enabled. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// `clientId` of the client that contributed this container. Absent for + /// server-originated entries. + /// + [JsonPropertyName("clientId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClientId { get; set; } + + /// + /// Host-reported load state. Absent means the host has not yet reported + /// a load state for this container. + /// + [JsonPropertyName("load")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomizationLoadState? Load { get; set; } + + /// + /// Children discovered inside this container. + /// + /// Absent means the host has not parsed this container yet. An empty + /// array means the host parsed the container and it contributes + /// nothing. + /// + [JsonPropertyName("children")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Which child customization type this directory holds. + /// + [JsonPropertyName("contents")] + public CustomizationType Contents { get; set; } + + /// + /// Whether clients may write into this directory. + /// + [JsonPropertyName("writable")] + public bool Writable { get; set; } +} + +/// +/// A custom agent contributed by a plugin or directory. +/// +/// Mirrors the [Open Plugins agent](https://open-plugins.com/agent-builders/components/agents) +/// format: a markdown file with YAML frontmatter, where the body is the +/// agent's system prompt. +/// +public sealed class AgentCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Short description of what the agent specializes in and when to + /// invoke it. Sourced from the agent file's frontmatter `description`. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// Additional provider-specific metadata for this custom agent. + /// + /// Mirrors the MCP `_meta` convention. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// A skill contributed by a plugin or directory. +/// +/// Covers both [Open Plugins skill formats](https://open-plugins.com/agent-builders/components/skills) +/// — the `skills/` directory layout (one subdirectory per skill, each with +/// a `SKILL.md`) and the flatter `commands/` directory of slash-command +/// skills. +/// +public sealed class SkillCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Short description used for help text and auto-invocation matching. + /// Sourced from the skill's frontmatter `description`. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// When `true`, only the user can invoke this skill — the agent will not + /// auto-invoke it. Sourced from the command skill's frontmatter + /// `disable-model-invocation` flag. + /// + [JsonPropertyName("disableModelInvocation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DisableModelInvocation { get; set; } +} + +/// +/// A prompt contributed by a plugin or directory. +/// +public sealed class PromptCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Short description of what the prompt does. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } +} + +/// +/// A rule contributed by a plugin or directory. +/// +/// Mirrors the [Open Plugins rule](https://open-plugins.com/agent-builders/components/rules) +/// format: a markdown file (e.g. `.mdc`) whose body is injected into +/// context while the rule is active. This type also covers tool-specific +/// "instruction" formats (e.g. VS Code Copilot's +/// `.github/instructions/*.md`), which differ only in naming — they +/// share the same semantics of `description`, optional always-on +/// activation, and optional glob scoping. +/// +public sealed class RuleCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Description of what the rule enforces. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// When `true`, the rule is always active (subject to `globs` if any). + /// When `false` or absent, the agent or user decides whether to apply + /// the rule. + /// + [JsonPropertyName("alwaysApply")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AlwaysApply { get; set; } + + /// + /// Glob patterns the rule applies to. When present, the rule is only + /// active for matching files. + /// + [JsonPropertyName("globs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Globs { get; set; } +} + +/// +/// A hook manifest contributed by a plugin or directory. +/// +public sealed class HookCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } +} + +/// +/// An MCP server contributed by a plugin or directory. +/// +/// When the server is declared inline in the containing plugin manifest, +/// `uri` points at the manifest file and +/// {@link CustomizationBase.range | `range`} narrows it to the +/// declaration's span. +/// +/// The MCP server customization also reflects its current status. +/// +public sealed class McpServerCustomization +{ + /// + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Source URI for this customization. A plugin URL, a file URI, or a + /// directory URI. + /// + /// For declarations that live inside a larger file — e.g. an MCP + /// server declared inline in a `plugins.json` manifest — `uri` points + /// to the containing file and {@link CustomizationBase.range | `range`} + /// narrows it to the declaration's span. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// Human-readable name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// Icons for UI display. + /// + [JsonPropertyName("icons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; set; } + + /// + /// Optional span within {@link CustomizationBase.uri | `uri`} when this + /// customization is a subset of a larger file (for example, one entry + /// in an inline `mcpServers` block of a `plugins.json` manifest). + /// Absent when the customization covers the whole resource. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + [JsonPropertyName("type")] + public CustomizationType Type { get; set; } + + /// + /// Whether this MCP server is currently enabled. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// Current lifecycle state of the MCP server. + /// + [JsonPropertyName("state")] + public required McpServerState State { get; set; } + + /// + /// An `mcp://`-protocol channel the client uses to side-channel traffic + /// into the upstream MCP server itself. The channel is NOT a fresh raw MCP + /// connection: it piggybacks on the AHP transport + /// and skips the MCP `initialize` sequence. + /// + /// The agent host MAY only serve a subset of MCP on this + /// channel; the served subset is described by domain-specific + /// capabilities such as those in + /// {@link McpServerCustomizationApps.capabilities}. + /// + /// The channel URI SHOULD be stable across the server's lifetime, but + /// the agent host MAY change it (for example across a restart) and + /// MAY only expose it while the server is in + /// {@link McpServerStatus.Ready | `Ready`}. Absence means no + /// side-channel is currently available. + /// + [JsonPropertyName("channel")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Channel { get; set; } + + /// + /// MCP App support. This property SHOULD be advertised for MCP servers + /// which support apps. + /// + [JsonPropertyName("mcpApp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public McpServerCustomizationApps? McpApp { get; set; } +} + +/// +/// Information from the agent host needed to render MCP Apps served +/// by this MCP server. +/// +public sealed class McpServerCustomizationApps +{ + /// + /// The subset of MCP App + /// [`HostCapabilities`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) + /// the AHP host can satisfy for Views backed by this server. The + /// client feeds these straight through into the `hostCapabilities` of + /// the `ui/initialize` response delivered to the View. + /// + [JsonPropertyName("capabilities")] + public required AhpMcpUiHostCapabilities Capabilities { get; set; } +} + +/// +/// The subset of MCP App +/// [`HostCapabilities`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) +/// an AHP host can derive from the upstream MCP server (and from AHP's own +/// forwarding plumbing). Advertised on +/// {@link McpServerCustomizationApps.capabilities} so clients can pass it +/// through into the `hostCapabilities` of the `ui/initialize` response +/// delivered to an MCP App View. +/// +/// Field names mirror the MCP Apps spec exactly, so the AHP-side producer +/// can pass them straight through into the `hostCapabilities` of the +/// `ui/initialize` response delivered to the View. +/// +/// Capabilities outside this set (`openLinks`, `downloadFile`, `sandbox`, +/// `experimental`) are decided locally by whichever AHP client renders the +/// View and are NOT part of this AHP-level advertisement — only the +/// server-derived subset is. +/// +/// An agent host MUST only advertise a capability when it actually accepts the +/// corresponding methods/notifications on the `mcp://` channel: +/// +/// - {@link serverTools}: host proxies `tools/list` and `tools/call` to +/// the MCP server. When `listChanged` is `true`, the host also forwards +/// `notifications/tools/list_changed`. +/// - {@link serverResources}: host proxies `resources/read`, +/// `resources/list`, and `resources/templates/list` to the MCP server. +/// When `listChanged` is `true`, the host also forwards +/// `notifications/resources/list_changed`. +/// - {@link logging}: host accepts `notifications/message` log entries +/// from the App and forwards them via `mcpNotification` (and forwards +/// `logging/setLevel` calls to the server). +/// - {@link sampling}: host serves `sampling/createMessage` via +/// `mcpMethodCall`. When `sampling.tools` is present, the host also +/// accepts SEP-1577 `tools` / `toolChoice` / `tool_use` content blocks +/// inside `CreateMessageRequest`. +/// +public sealed class AhpMcpUiHostCapabilities +{ + /// + /// Producer proxies the MCP `tools/*` methods to the upstream server. + /// + [JsonPropertyName("serverTools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? ServerTools { get; set; } + + /// + /// Producer proxies the MCP `resources/*` methods to the upstream server. + /// + [JsonPropertyName("serverResources")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? ServerResources { get; set; } + + /// + /// Producer accepts `notifications/message` log entries from the App via `mcpNotification`. + /// + [JsonPropertyName("logging")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Logging { get; set; } + + /// + /// Producer serves `sampling/createMessage` via `mcpMethodCall`. + /// + [JsonPropertyName("sampling")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Sampling { get; set; } +} + +/// +/// Server is registered with the host but has not yet started. +/// +public sealed class McpServerStartingState +{ + [JsonPropertyName("kind")] + public McpServerStatus Kind { get; set; } +} + +/// +/// Server is running and serving requests. +/// +public sealed class McpServerReadyState +{ + [JsonPropertyName("kind")] + public McpServerStatus Kind { get; set; } +} + +/// +/// Server is reachable but cannot serve requests until the client +/// authenticates. Mirrors the discovery flow defined by +/// [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) +/// (Protected Resource Metadata) and the OAuth 2.1 / RFC 6750 challenge +/// semantics required by the MCP authorization spec. +/// +/// Clients react to this state by calling the existing `authenticate` +/// command with the {@link ProtectedResourceMetadata.resource | resource} +/// carried here. There is **no** `notify/authRequired` notification for +/// MCP servers — the action stream is the single source of truth. +/// +/// When the transition is triggered by a request issued during a turn +/// — most commonly +/// {@link McpAuthRequiredReason.InsufficientScope | `InsufficientScope`} +/// surfacing mid-tool-call — the host SHOULD also raise +/// {@link SessionStatus.InputNeeded} on the session so the block is +/// visible at the summary level. Clients SHOULD watch this status on +/// any MCP server backing a running tool call and surface an explicit +/// affordance (e.g. a "grant additional access" prompt) tied to that +/// tool call, rather than relying on the user to notice the +/// customization’s status badge. +/// +public sealed class McpServerAuthRequiredState +{ + [JsonPropertyName("kind")] + public McpServerStatus Kind { get; set; } + + /// + /// Why authentication is required. + /// + [JsonPropertyName("reason")] + public McpAuthRequiredReason Reason { get; set; } + + /// + /// RFC 9728 Protected Resource Metadata. The `resource` field is the + /// canonical MCP server URI per RFC 8707, used as the OAuth `resource` + /// indicator. `authorization_servers` is REQUIRED by the MCP + /// authorization spec. + /// + [JsonPropertyName("resource")] + public required ProtectedResourceMetadata Resource { get; set; } + + /// + /// Scopes required for the current challenge, parsed from the + /// `WWW-Authenticate: Bearer scope="…"` header (or `scopes_supported` + /// fallback). Authoritative for the next authorization request — clients + /// MUST NOT assume any subset/superset relationship to + /// `resource.scopes_supported`. + /// + [JsonPropertyName("requiredScopes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? RequiredScopes { get; set; } + + /// + /// Human-readable hint, typically from the OAuth `error_description`. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } +} + +/// +/// Server failed to start, crashed, or otherwise transitioned to a +/// non-recoverable error. Use {@link McpServerStatus.AuthRequired} +/// for authentication failures. +/// +public sealed class McpServerErrorState +{ + [JsonPropertyName("kind")] + public McpServerStatus Kind { get; set; } + + /// + /// Error details. + /// + [JsonPropertyName("error")] + public required ErrorInfo Error { get; set; } +} + +/// +/// Server has been shut down. The host MAY remove the server from the +/// session entirely shortly after this state. +/// +public sealed class McpServerStoppedState +{ + [JsonPropertyName("kind")] + public McpServerStatus Kind { get; set; } +} + +public sealed class ToolCallClientContributor +{ + [JsonPropertyName("kind")] + public ToolCallContributorKind Kind { get; set; } + + /// + /// If this tool is provided by a client, the `clientId` of the owning client. + /// Absent for server-side tools. + /// + /// When set, the identified client is responsible for executing the tool and + /// dispatching `session/toolCallComplete` with the result. + /// + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; +} + +public sealed class ToolCallMcpContributor +{ + [JsonPropertyName("kind")] + public ToolCallContributorKind Kind { get; set; } + + /// + /// Customization ID of the corresponding MCP server in {@link SessionState.customizations}. + /// + [JsonPropertyName("customizationId")] + public string CustomizationId { get; set; } = ""; +} + +/// +/// Describes a file modification with before/after state and diff metadata. +/// +/// Supports creates (only `after`), deletes (only `before`), renames/moves +/// (different `uri` in `before` and `after`), and edits (same `uri`, different content). +/// +public sealed class FileEdit +{ + /// + /// The file state before the edit. Absent for file creations or for in-place file edits. + /// + [JsonPropertyName("before")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Before { get; set; } + + /// + /// The file state after the edit. Absent for file deletions. + /// + [JsonPropertyName("after")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? After { get; set; } + + /// + /// Optional diff display metadata + /// + [JsonPropertyName("diff")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Diff { get; set; } +} + +/// +/// Lightweight terminal metadata exposed on the root state. +/// +public sealed class TerminalInfo +{ + /// + /// Terminal URI (subscribable for full terminal state) + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Human-readable terminal title + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + /// + /// Who currently holds this terminal + /// + [JsonPropertyName("claim")] + public required TerminalClaim Claim { get; set; } + + /// + /// Process exit code, if the terminal process has exited + /// + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } +} + +/// +/// A terminal claimed by a connected client. +/// +public sealed class TerminalClientClaim +{ + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public TerminalClaimKind Kind { get; set; } + + /// + /// The `clientId` of the claiming client + /// + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; +} + +/// +/// A terminal claimed by a session, optionally scoped to a specific turn or tool call. +/// +public sealed class TerminalSessionClaim +{ + /// + /// Discriminant + /// + [JsonPropertyName("kind")] + public TerminalClaimKind Kind { get; set; } + + /// + /// Session URI that claimed the terminal + /// + [JsonPropertyName("session")] + public string Session { get; set; } = ""; + + /// + /// Optional turn identifier within the session + /// + [JsonPropertyName("turnId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; set; } + + /// + /// Optional tool call identifier within the turn + /// + [JsonPropertyName("toolCallId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolCallId { get; set; } +} + +/// +/// Full state for a single terminal, loaded when a client subscribes to the terminal's URI. +/// +public sealed class TerminalState +{ + /// + /// Human-readable terminal title + /// + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + /// + /// Current working directory of the terminal process + /// + [JsonPropertyName("cwd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cwd { get; set; } + + /// + /// Terminal width in columns + /// + [JsonPropertyName("cols")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Cols { get; set; } + + /// + /// Terminal height in rows + /// + [JsonPropertyName("rows")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Rows { get; set; } + + /// + /// Typed content parts, replacing the flat `content: string`. + /// + /// Naive consumers that only need the raw VT stream can reconstruct it with: + /// `content.map(p => p.type === 'command' ? p.output : p.value).join('')` + /// + /// Consumers that need command boundaries can filter by part type. + /// + [JsonPropertyName("content")] + public List Content { get; set; } = null!; + + /// + /// Process exit code, set when the terminal process exits + /// + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } + + /// + /// Who currently holds this terminal + /// + [JsonPropertyName("claim")] + public required TerminalClaim Claim { get; set; } + + /// + /// Whether this terminal emits `terminal/commandExecuted` and + /// `terminal/commandFinished` actions and populates `command`-typed parts. + /// + /// Clients MUST check this flag before relying on command detection. + /// Do NOT use the presence of a `command` part as a feature flag — parts + /// are absent in the normal idle state. + /// + [JsonPropertyName("supportsCommandDetection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SupportsCommandDetection { get; set; } +} + +/// +/// Unstructured terminal output — content before, between, or after commands, +/// or from terminals that do not support command detection. +/// +public sealed class TerminalUnclassifiedPart +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// + /// Accumulated VT output. Appended to by `terminal/data` when no command is executing. + /// + [JsonPropertyName("value")] + public string Value { get; set; } = ""; +} + +/// +/// A single command: its command line and the output it produced. +/// +/// While `isComplete` is false the command is still executing; `output` grows +/// as `terminal/data` actions arrive. At `terminal/commandFinished` the part +/// is mutated in-place with `isComplete: true` and the completion metadata. +/// +public sealed class TerminalCommandPart +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// + /// Stable id matching the `commandId` on the corresponding + /// `terminal/commandExecuted` and `terminal/commandFinished` actions. + /// + [JsonPropertyName("commandId")] + public string CommandId { get; set; } = ""; + + /// + /// The command line submitted to the shell. + /// + [JsonPropertyName("commandLine")] + public string CommandLine { get; set; } = ""; + + /// + /// Accumulated VT output. Appended to by `terminal/data` while `isComplete` + /// is false. Shell integration escape sequences are stripped by the server. + /// + [JsonPropertyName("output")] + public string Output { get; set; } = ""; + + /// + /// Unix timestamp (ms) when execution started, as reported by the server. + /// + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + /// + /// Whether the command has finished. + /// + [JsonPropertyName("isComplete")] + public bool IsComplete { get; set; } + + /// + /// Shell exit code. Set at completion. `undefined` if unknown. + /// + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } + + /// + /// Wall-clock duration in milliseconds. Set at completion. + /// + [JsonPropertyName("durationMs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? DurationMs { get; set; } +} + +public sealed class UsageInfo +{ + /// + /// Input tokens consumed + /// + [JsonPropertyName("inputTokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? InputTokens { get; set; } + + /// + /// Output tokens generated + /// + [JsonPropertyName("outputTokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? OutputTokens { get; set; } + + /// + /// Model used + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Model { get; set; } + + /// + /// Tokens read from cache + /// + [JsonPropertyName("cacheReadTokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? CacheReadTokens { get; set; } + + /// + /// Additional provider-specific metadata for this usage report. + /// Clients MAY look for well-known optional keys here to provide enhanced UI. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +public sealed class ErrorInfo +{ + /// + /// Error type identifier + /// + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } = ""; + + /// + /// Human-readable error message + /// + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + /// + /// Stack trace + /// + [JsonPropertyName("stack")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Stack { get; set; } +} + +/// +/// A point-in-time snapshot of a subscribed resource's state, returned by +/// `initialize`, `reconnect`, and `subscribe`. +/// +public sealed class Snapshot +{ + /// + /// The subscribed channel URI (e.g. `ahp-root://` or `ahp-session:/<uuid>`) + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// The current state of the resource + /// + [JsonPropertyName("state")] + public required SnapshotState State { get; set; } + + /// + /// The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. + /// + [JsonPropertyName("fromSeq")] + public long FromSeq { get; set; } +} + +/// +/// Catalogue entry describing one changeset the server can produce for a +/// session. +/// +/// Catalogue entries are intentionally lightweight — just enough to render a +/// chip or list row without subscribing. Full per-changeset detail +/// ({@link ChangesetState}) lives on the subscribable URI obtained by +/// expanding {@link uriTemplate}. +/// +public sealed class Changeset +{ + /// + /// Human-readable label, e.g. `"Uncommitted Changes"`. + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// RFC 6570 URI template. Clients parse the variables directly out of the + /// template using the standard `{name}` syntax — they are not redeclared + /// here. + /// + /// Only the following template shapes are defined by this protocol; any + /// other variable name MUST be ignored by clients (there is no + /// protocol-defined way to obtain values for unknown variables): + /// + /// | Variables in template | Meaning | + /// | ------------------------------------------- | ------------------------------------------------------------------------------------ | + /// | _(none)_ | A static, session-wide changeset. The template is itself a subscribable URI. | + /// | `{turnId}` | Per-turn slice. Expand with a `Turn.id` from the session. | + /// | `{originalTurnId}` and `{modifiedTurnId}` | Diff between two turns. Both variables MUST be present. | + /// + /// Future protocol versions MAY add new well-known variables. + /// + [JsonPropertyName("uriTemplate")] + public string UriTemplate { get; set; } = ""; + + /// + /// Optional longer description. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// Advisory hint describing what kind of changeset this is, so clients can + /// group, sort, or render an appropriate icon without parsing + /// {@link uriTemplate}. Recognized values include: + /// + /// - `'session'`: a static, session-wide changeset covering all changes the + /// agent has produced in this session. + /// - `'branch'`: changes relative to a base branch (e.g. a feature branch + /// diffed against `main`). + /// - `'uncommitted'`: the workspace's current uncommitted changes. + /// - `'turn'`: changes produced by a single turn. Typically paired with a + /// `{turnId}` variable in {@link uriTemplate}. + /// - `'compare-turns'`: a diff between two turns. Typically paired with + /// `{originalTurnId}` and `{modifiedTurnId}` variables in + /// {@link uriTemplate}. + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + /// + [JsonPropertyName("changeKind")] + public string ChangeKind { get; set; } = ""; +} + +/// +/// Full state for a single changeset, returned when a client subscribes to +/// an expanded changeset URI. +/// +/// The client already knows the URI it subscribed to, so this state does +/// not redundantly carry it (or the catalogue's `id`, `label`, etc.). +/// Aggregate counts (`additions`, `deletions`, `files`) are likewise +/// omitted: clients trivially compute them from `files[].edit.diff`. +/// +public sealed class ChangesetState +{ + /// + /// Computation lifecycle. + /// + [JsonPropertyName("status")] + public ChangesetStatus Status { get; set; } + + /// + /// Present iff `status === ChangesetStatus.Error`. + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; set; } + + /// + /// Files in this changeset, keyed by {@link ChangesetFile.id}. + /// + [JsonPropertyName("files")] + public List Files { get; set; } = null!; + + /// + /// Operations the client may invoke against this changeset. Omit when no + /// operations are available. + /// + [JsonPropertyName("operations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Operations { get; set; } +} + +/// +/// One file entry within a {@link ChangesetState}. +/// +public sealed class ChangesetFile +{ + /// + /// Stable identifier within the changeset. Typically `after.uri` + /// (or `before.uri` for deletions). + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Reuses the existing {@link FileEdit} shape. Clients derive line + /// additions, deletions, and rename/create/delete semantics from this. + /// + [JsonPropertyName("edit")] + public required FileEdit Edit { get; set; } + + /// + /// Server-defined opaque metadata, surfaced to operations and tooling + /// but not interpreted by the protocol. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// A server-declared invokable verb the client can run against a +/// changeset, a file, or a range — `"stage"`, `"revert"`, `"create-pr"`, +/// and so on. +/// +/// The term "operation" is used deliberately to avoid colliding with the +/// protocol-level [Actions](/guide/actions) that mutate state. +/// +public sealed class ChangesetOperation +{ + /// + /// Stable identifier, unique within this changeset. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Human-readable button/menu label. + /// + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// Optional longer description shown on hover or in tooltips. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// + /// Where this operation can be invoked. + /// + [JsonPropertyName("scopes")] + public List Scopes { get; set; } = null!; + + /// + /// Optional confirmation prompt to show before invoking. When present, + /// the client MUST display this message to the user (typically in a + /// confirmation dialog) and only invoke the operation after the user + /// accepts. The presence of this field also signals that the operation + /// is destructive — clients SHOULD style the affirmative button + /// accordingly (e.g. with a warning colour). + /// + [JsonPropertyName("confirmation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? Confirmation { get; set; } + + /// + /// Optional generic icon hint, e.g. `"check"`, `"trash"`. + /// + [JsonPropertyName("icon")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Icon { get; set; } + + /// + /// Current execution status. The server sets + /// {@link ChangesetOperationStatus.Running | Running} while an invocation + /// is in flight, {@link ChangesetOperationStatus.Error | Error} when the + /// most recent invocation failed, and + /// {@link ChangesetOperationStatus.Idle | Idle} otherwise. + /// + /// Clients SHOULD reflect this state in the UI — e.g. disabling the + /// control or showing a spinner while `Running`, and surfacing + /// {@link error} while `Error`. + /// + [JsonPropertyName("status")] + public ChangesetOperationStatus Status { get; set; } + + /// + /// Cause of failure. Present iff + /// `status === ChangesetOperationStatus.Error`; otherwise omitted. + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; set; } +} + +/// +/// OTLP telemetry channels the agent host emits. +/// +/// Each field, when present, is either a literal channel URI or an +/// [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template +/// a client expands and then subscribes to. Absent fields indicate the host +/// does not emit that signal. +/// +/// Channel URIs use the `ahp-otlp:` scheme. The scheme identifies the +/// protocol (OpenTelemetry over AHP) so clients can recognise the channel +/// type by URI alone; the host is free to choose any authority/path that +/// makes sense for its implementation. Clients MUST treat the URI as +/// opaque (apart from expanding any well-known template variables defined +/// below) and subscribe with the resulting concrete URI. +/// +/// Payloads delivered on these channels are OTLP/JSON values — see +/// [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto) +/// for the wire shapes (`ExportLogsServiceRequest`, +/// `ExportTraceServiceRequest`, `ExportMetricsServiceRequest`). +/// +public sealed class TelemetryCapabilities +{ + /// + /// Channel URI (or RFC 6570 URI template) for OTLP log records + /// (`otlp/exportLogs` notifications). + /// + /// The following template variables are defined by this protocol; any + /// other variable name MUST be ignored by clients (there is no + /// protocol-defined way to obtain values for unknown variables): + /// + /// | Variables in template | Meaning | + /// | --------------------- | ------------------------------------------------------------------------------------------------------- | + /// | _(none)_ | The host does not support subscriber-side severity filtering. The template is itself a subscribable URI. | + /// | `{level}` | Minimum OTLP severity to deliver. Expand to one of the [OTLP `SeverityNumber`](https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber) short names (case-insensitive): `trace`, `debug`, `info`, `warn`, `error`, `fatal`. The server delivers log records whose `severityNumber` falls in the corresponding band or above. | + /// + /// Hosts SHOULD honour the expanded `{level}`; clients MUST still filter + /// defensively in case a host ignores the parameter. Hosts that do not + /// advertise `{level}` deliver all severities. + /// + /// Future protocol versions MAY add new well-known variables (e.g. scope + /// or attribute filters). + /// + [JsonPropertyName("logs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Logs { get; set; } + + /// + /// Channel URI for OTLP spans (`otlp/exportTraces` notifications). No + /// template variables are defined by this protocol version. + /// + [JsonPropertyName("traces")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Traces { get; set; } + + /// + /// Channel URI for OTLP metric data points (`otlp/exportMetrics` + /// notifications). No template variables are defined by this protocol + /// version. + /// + [JsonPropertyName("metrics")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Metrics { get; set; } +} + +/// +/// Full state for a single resource watch, returned when a client subscribes +/// to an `ahp-resource-watch:` URI. +/// +/// Watches are otherwise stateless: the watcher exists to deliver +/// {@link ResourceWatchChangedAction} events. The state carries only the +/// descriptor of what is being watched so a re-subscribing client can +/// recover the watch configuration after reconnecting. +/// +public sealed class ResourceWatchState +{ + /// + /// The URI being watched. For recursive watches this is the root of the + /// subtree; for non-recursive watches this is the single file or + /// directory. + /// + [JsonPropertyName("root")] + public string Root { get; set; } = ""; + + /// + /// `true` if the watcher reports changes for descendants of `root`; + /// `false` if it only reports changes to `root` itself (and, when + /// `root` is a directory, its direct children). + /// + [JsonPropertyName("recursive")] + public bool Recursive { get; set; } + + /// + /// Optional glob patterns or paths relative to `root` to exclude from + /// change reporting. + /// + [JsonPropertyName("excludes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Excludes { get; set; } + + /// + /// Optional glob patterns or paths relative to `root` to restrict + /// change reporting to. Omit to report every change under `root` + /// subject to `excludes`. + /// + [JsonPropertyName("includes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Includes { get; set; } +} + +/// +/// A single change observed by a resource watcher. +/// +public sealed class ResourceChange +{ + /// + /// The URI of the resource that changed. + /// + [JsonPropertyName("uri")] + public string Uri { get; set; } = ""; + + /// + /// The kind of change observed. + /// + [JsonPropertyName("type")] + public ResourceChangeType Type { get; set; } +} + +/// +/// Lightweight per-session summary of the annotations channel, surfaced on +/// {@link SessionSummary.annotations} so badge UI can render annotation / +/// entry counts without subscribing to the channel itself. +/// +public sealed class AnnotationsSummary +{ + /// + /// The subscribable annotations channel URI for the owning session + /// (typically `ahp-session:/<uuid>/annotations`). Surfaced explicitly even + /// though it is derivable from the session URI so badge UI does not need + /// to know the derivation rule. + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Total number of {@link Annotation} entries in the channel. + /// + [JsonPropertyName("annotationCount")] + public long AnnotationCount { get; set; } + + /// + /// Total number of {@link AnnotationEntry} entries across every annotation. + /// + [JsonPropertyName("entryCount")] + public long EntryCount { get; set; } +} + +/// +/// Full state for a session's annotations channel, returned when a client +/// subscribes to an `ahp-session:/<uuid>/annotations` URI. +/// +public sealed class AnnotationsState +{ + /// + /// Annotations in this channel, keyed by {@link Annotation.id}. + /// + [JsonPropertyName("annotations")] + public List Annotations { get; set; } = null!; +} + +/// +/// A conversation anchored to a specific file produced by a specific turn, +/// optionally narrowed to a range within that file. +/// +/// {@link turnId} anchors the annotation to the file versions that turn +/// produced, so a later turn that rewrites the same file does not silently +/// invalidate the annotation's anchor — clients can resolve {@link resource} +/// and {@link range} against the turn's changeset. When {@link range} is +/// omitted the annotation is anchored to the entire file. +/// +/// Every annotation MUST contain at least one {@link AnnotationEntry}. An +/// {@link AnnotationsSetAction} that creates an annotation therefore carries +/// its mandatory first entry, and removing the last remaining entry collapses +/// the annotation via {@link AnnotationsRemovedAction} rather than leaving an +/// empty annotation behind. +/// +public sealed class Annotation +{ + /// + /// Stable identifier within the annotations channel. Assigned by the client + /// that dispatches the creating {@link AnnotationsSetAction}. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Turn that produced the file versions this annotation is anchored to. + /// Matches a {@link Turn.id} on the owning session. + /// + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + /// + /// The file the annotation is anchored to. + /// + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + /// + /// Range within {@link resource} the annotation is anchored to. When + /// omitted the annotation is anchored to the entire file. + /// + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// + /// Whether the annotation has been resolved. Newly created annotations are + /// always unresolved (`false`); a client marks an annotation resolved (or + /// re-opens it) by dispatching an {@link AnnotationsSetAction} carrying the + /// updated flag. + /// + [JsonPropertyName("resolved")] + public bool Resolved { get; set; } + + /// + /// Entries in this annotation, in dispatch order (oldest first). MUST + /// contain at least one entry. + /// + [JsonPropertyName("entries")] + public List Entries { get; set; } = null!; + + /// + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// +/// A single entry within an {@link Annotation}. +/// +public sealed class AnnotationEntry +{ + /// + /// Stable identifier within the enclosing annotation. Assigned by the client + /// that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + /// {@link AnnotationsSetAction}) introducing the entry. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// Entry body. A bare `string` is rendered as plain text; pass + /// `{ markdown: "…" }` to opt into Markdown rendering. See + /// {@link StringOrMarkdown}. + /// + [JsonPropertyName("text")] + public StringOrMarkdown Text { get; set; } = new(); + + /// + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + /// + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +// ─── Discriminated Unions ───────────────────────────────────────────── + +/// +/// ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference). +/// +[JsonConverter(typeof(ResponsePartConverter))] +public sealed class ResponsePart : AhpUnion +{ + /// Creates an empty ResponsePart (no active variant). + public ResponsePart() { } + + /// Creates a ResponsePart wrapping the given variant value. + public ResponsePart(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ResponsePart discriminated union. +internal sealed class ResponsePartConverter : UnionConverter +{ + public ResponsePartConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["markdown"] = typeof(MarkdownResponsePart), + ["contentRef"] = typeof(ResourceResponsePart), + ["toolCall"] = typeof(ToolCallResponsePart), + ["reasoning"] = typeof(ReasoningResponsePart), + ["systemNotification"] = typeof(SystemNotificationResponsePart), + }, + allowUnknown: true) + { + } +} + +/// +/// ToolCallState is the full tool call lifecycle state. +/// +[JsonConverter(typeof(ToolCallStateConverter))] +public sealed class ToolCallState : AhpUnion +{ + /// Creates an empty ToolCallState (no active variant). + public ToolCallState() { } + + /// Creates a ToolCallState wrapping the given variant value. + public ToolCallState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ToolCallState discriminated union. +internal sealed class ToolCallStateConverter : UnionConverter +{ + public ToolCallStateConverter() + : base( + discriminator: "status", + variants: new Dictionary + { + ["streaming"] = typeof(ToolCallStreamingState), + ["pending-confirmation"] = typeof(ToolCallPendingConfirmationState), + ["running"] = typeof(ToolCallRunningState), + ["pending-result-confirmation"] = typeof(ToolCallPendingResultConfirmationState), + ["completed"] = typeof(ToolCallCompletedState), + ["cancelled"] = typeof(ToolCallCancelledState), + }, + allowUnknown: true) + { + } +} + +/// +/// TerminalClaim identifies who currently holds a terminal. +/// +[JsonConverter(typeof(TerminalClaimConverter))] +public sealed class TerminalClaim : AhpUnion +{ + /// Creates an empty TerminalClaim (no active variant). + public TerminalClaim() { } + + /// Creates a TerminalClaim wrapping the given variant value. + public TerminalClaim(object? value) : base(value) { } +} + +/// System.Text.Json converter for the TerminalClaim discriminated union. +internal sealed class TerminalClaimConverter : UnionConverter +{ + public TerminalClaimConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["client"] = typeof(TerminalClientClaim), + ["session"] = typeof(TerminalSessionClaim), + }, + allowUnknown: true) + { + } +} + +/// +/// TerminalContentPart is a content part within terminal output. +/// +[JsonConverter(typeof(TerminalContentPartConverter))] +public sealed class TerminalContentPart : AhpUnion +{ + /// Creates an empty TerminalContentPart (no active variant). + public TerminalContentPart() { } + + /// Creates a TerminalContentPart wrapping the given variant value. + public TerminalContentPart(object? value) : base(value) { } +} + +/// System.Text.Json converter for the TerminalContentPart discriminated union. +internal sealed class TerminalContentPartConverter : UnionConverter +{ + public TerminalContentPartConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["unclassified"] = typeof(TerminalUnclassifiedPart), + ["command"] = typeof(TerminalCommandPart), + }, + allowUnknown: true) + { + } +} + +/// +/// SessionInputQuestion is one question within a session input request. +/// +[JsonConverter(typeof(SessionInputQuestionConverter))] +public sealed class SessionInputQuestion : AhpUnion +{ + /// Creates an empty SessionInputQuestion (no active variant). + public SessionInputQuestion() { } + + /// Creates a SessionInputQuestion wrapping the given variant value. + public SessionInputQuestion(object? value) : base(value) { } +} + +/// System.Text.Json converter for the SessionInputQuestion discriminated union. +internal sealed class SessionInputQuestionConverter : UnionConverter +{ + public SessionInputQuestionConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["text"] = typeof(SessionInputTextQuestion), + ["number"] = typeof(SessionInputNumberQuestion), + ["integer"] = typeof(SessionInputNumberQuestion), + ["boolean"] = typeof(SessionInputBooleanQuestion), + ["single-select"] = typeof(SessionInputSingleSelectQuestion), + ["multi-select"] = typeof(SessionInputMultiSelectQuestion), + }, + allowUnknown: true) + { + } +} + +/// +/// SessionInputAnswerValue is the value captured for one answer. +/// +[JsonConverter(typeof(SessionInputAnswerValueConverter))] +public sealed class SessionInputAnswerValue : AhpUnion +{ + /// Creates an empty SessionInputAnswerValue (no active variant). + public SessionInputAnswerValue() { } + + /// Creates a SessionInputAnswerValue wrapping the given variant value. + public SessionInputAnswerValue(object? value) : base(value) { } +} + +/// System.Text.Json converter for the SessionInputAnswerValue discriminated union. +internal sealed class SessionInputAnswerValueConverter : UnionConverter +{ + public SessionInputAnswerValueConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["text"] = typeof(SessionInputTextAnswerValue), + ["number"] = typeof(SessionInputNumberAnswerValue), + ["boolean"] = typeof(SessionInputBooleanAnswerValue), + ["selected"] = typeof(SessionInputSelectedAnswerValue), + ["selected-many"] = typeof(SessionInputSelectedManyAnswerValue), + }, + allowUnknown: true) + { + } +} + +/// +/// SessionInputAnswer is a draft, submitted, or skipped answer for one question. +/// +[JsonConverter(typeof(SessionInputAnswerConverter))] +public sealed class SessionInputAnswer : AhpUnion +{ + /// Creates an empty SessionInputAnswer (no active variant). + public SessionInputAnswer() { } + + /// Creates a SessionInputAnswer wrapping the given variant value. + public SessionInputAnswer(object? value) : base(value) { } +} + +/// System.Text.Json converter for the SessionInputAnswer discriminated union. +internal sealed class SessionInputAnswerConverter : UnionConverter +{ + public SessionInputAnswerConverter() + : base( + discriminator: "state", + variants: new Dictionary + { + ["draft"] = typeof(SessionInputAnswered), + ["submitted"] = typeof(SessionInputAnswered), + ["skipped"] = typeof(SessionInputSkipped), + }, + allowUnknown: true) + { + } +} + +/// +/// ToolResultContent is a content block in a tool result. +/// +[JsonConverter(typeof(ToolResultContentConverter))] +public sealed class ToolResultContent : AhpUnion +{ + /// Creates an empty ToolResultContent (no active variant). + public ToolResultContent() { } + + /// Creates a ToolResultContent wrapping the given variant value. + public ToolResultContent(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ToolResultContent discriminated union. +internal sealed class ToolResultContentConverter : UnionConverter +{ + public ToolResultContentConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["text"] = typeof(ToolResultTextContent), + ["embeddedResource"] = typeof(ToolResultEmbeddedResourceContent), + ["resource"] = typeof(ToolResultResourceContent), + ["fileEdit"] = typeof(ToolResultFileEditContent), + ["terminal"] = typeof(ToolResultTerminalContent), + ["subagent"] = typeof(ToolResultSubagentContent), + }, + allowUnknown: true) + { + } +} + +/// +/// MessageAttachment is an attachment associated with a Message. +/// +[JsonConverter(typeof(MessageAttachmentConverter))] +public sealed class MessageAttachment : AhpUnion +{ + /// Creates an empty MessageAttachment (no active variant). + public MessageAttachment() { } + + /// Creates a MessageAttachment wrapping the given variant value. + public MessageAttachment(object? value) : base(value) { } +} + +/// System.Text.Json converter for the MessageAttachment discriminated union. +internal sealed class MessageAttachmentConverter : UnionConverter +{ + public MessageAttachmentConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["simple"] = typeof(SimpleMessageAttachment), + ["embeddedResource"] = typeof(MessageEmbeddedResourceAttachment), + ["resource"] = typeof(MessageResourceAttachment), + ["annotations"] = typeof(MessageAnnotationsAttachment), + }, + allowUnknown: true) + { + } +} + +/// +/// Customization is a top-level customization (plugin, directory, or MCP server). +/// +[JsonConverter(typeof(CustomizationConverter))] +public sealed class Customization : AhpUnion +{ + /// Creates an empty Customization (no active variant). + public Customization() { } + + /// Creates a Customization wrapping the given variant value. + public Customization(object? value) : base(value) { } +} + +/// System.Text.Json converter for the Customization discriminated union. +internal sealed class CustomizationConverter : UnionConverter +{ + public CustomizationConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["plugin"] = typeof(PluginCustomization), + ["directory"] = typeof(DirectoryCustomization), + ["mcpServer"] = typeof(McpServerCustomization), + }, + allowUnknown: true) + { + } +} + +/// +/// ChildCustomization is a child customization living inside a plugin or directory. +/// +[JsonConverter(typeof(ChildCustomizationConverter))] +public sealed class ChildCustomization : AhpUnion +{ + /// Creates an empty ChildCustomization (no active variant). + public ChildCustomization() { } + + /// Creates a ChildCustomization wrapping the given variant value. + public ChildCustomization(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ChildCustomization discriminated union. +internal sealed class ChildCustomizationConverter : UnionConverter +{ + public ChildCustomizationConverter() + : base( + discriminator: "type", + variants: new Dictionary + { + ["agent"] = typeof(AgentCustomization), + ["skill"] = typeof(SkillCustomization), + ["prompt"] = typeof(PromptCustomization), + ["rule"] = typeof(RuleCustomization), + ["hook"] = typeof(HookCustomization), + ["mcpServer"] = typeof(McpServerCustomization), + }, + allowUnknown: true) + { + } +} + +/// +/// CustomizationLoadState is the host-reported load state for a container customization. +/// +[JsonConverter(typeof(CustomizationLoadStateConverter))] +public sealed class CustomizationLoadState : AhpUnion +{ + /// Creates an empty CustomizationLoadState (no active variant). + public CustomizationLoadState() { } + + /// Creates a CustomizationLoadState wrapping the given variant value. + public CustomizationLoadState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CustomizationLoadState discriminated union. +internal sealed class CustomizationLoadStateConverter : UnionConverter +{ + public CustomizationLoadStateConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["loading"] = typeof(CustomizationLoadingState), + ["loaded"] = typeof(CustomizationLoadedState), + ["degraded"] = typeof(CustomizationDegradedState), + ["error"] = typeof(CustomizationErrorState), + }, + allowUnknown: true) + { + } +} + +/// +/// McpServerState is the lifecycle state of an MCP server customization. +/// +[JsonConverter(typeof(McpServerStateConverter))] +public sealed class McpServerState : AhpUnion +{ + /// Creates an empty McpServerState (no active variant). + public McpServerState() { } + + /// Creates a McpServerState wrapping the given variant value. + public McpServerState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the McpServerState discriminated union. +internal sealed class McpServerStateConverter : UnionConverter +{ + public McpServerStateConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["starting"] = typeof(McpServerStartingState), + ["ready"] = typeof(McpServerReadyState), + ["authRequired"] = typeof(McpServerAuthRequiredState), + ["error"] = typeof(McpServerErrorState), + ["stopped"] = typeof(McpServerStoppedState), + }, + allowUnknown: true) + { + } +} + +/// +/// ToolCallContributor identifies who provides a tool call (client or MCP server). +/// +[JsonConverter(typeof(ToolCallContributorConverter))] +public sealed class ToolCallContributor : AhpUnion +{ + /// Creates an empty ToolCallContributor (no active variant). + public ToolCallContributor() { } + + /// Creates a ToolCallContributor wrapping the given variant value. + public ToolCallContributor(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ToolCallContributor discriminated union. +internal sealed class ToolCallContributorConverter : UnionConverter +{ + public ToolCallContributorConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["client"] = typeof(ToolCallClientContributor), + ["mcp"] = typeof(ToolCallMcpContributor), + }, + allowUnknown: true) + { + } +} + +/// +/// SnapshotState is the state payload of a snapshot — root, session, +/// terminal, or changeset state. UnmarshalJSON probes for required fields +/// in the canonical order (session → terminal → changeset → root). +/// +[JsonConverter(typeof(SnapshotStateConverter))] +public sealed class SnapshotState +{ + /// Root state variant, when populated. + public RootState? Root { get; set; } + + /// Session state variant, when populated. + public SessionState? Session { get; set; } + + /// Terminal state variant, when populated. + public TerminalState? Terminal { get; set; } + + /// Changeset state variant, when populated. + public ChangesetState? Changeset { get; set; } +} + +/// System.Text.Json converter for the SnapshotState shape-probed union. +internal sealed class SnapshotStateConverter : JsonConverter +{ + public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var result = new SnapshotState(); + if (root.TryGetProperty("summary", out _) && root.TryGetProperty("lifecycle", out _)) + { + result.Session = root.Deserialize(options); + } + else if (root.TryGetProperty("content", out _)) + { + result.Terminal = root.Deserialize(options); + } + else if (root.TryGetProperty("status", out _) && root.TryGetProperty("files", out _)) + { + result.Changeset = root.Deserialize(options); + } + else + { + result.Root = root.Deserialize(options); + } + return result; + } + + public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) + { + if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, options); return; } + if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, options); return; } + if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, options); return; } + if (value.Root is not null) { JsonSerializer.Serialize(writer, value.Root, options); return; } + writer.WriteNullValue(); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs new file mode 100644 index 00000000..330e9f2d --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -0,0 +1,39 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +/// Protocol version constants for the Agent Host Protocol. +public static class ProtocolVersion +{ + /// + /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this + /// generated source speaks. + /// + public const string Current = "0.4.0"; + + private static readonly string[] s_supported = + { + "0.4.0", + "0.3.0", + }; + + /// + /// Every protocol version this client is willing to negotiate, ordered + /// most-preferred-first. The first entry always equals . + /// A fresh copy is returned on every call so callers may mutate it freely. + /// + public static IReadOnlyList Supported => (string[])s_supported.Clone(); + + /// The well-known channel URI for the root channel. + public const string RootResourceUri = "ahp-root://"; +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs new file mode 100644 index 00000000..a287f30b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs @@ -0,0 +1,35 @@ +// Hand-written support for the generated discriminated-union wrappers. +// Not regenerated by `npm run generate:dotnet`. +#nullable enable + +namespace Microsoft.AgentHostProtocol; + +/// +/// Base class for every generated discriminated-union wrapper (for example +/// StateAction, ResponsePart, ToolCallState). The active +/// variant is stored in as the concrete payload type; +/// unknown discriminator values (introduced by a newer protocol version) are +/// stored as a raw so re-encoding +/// round-trips faithfully. +/// +public abstract class AhpUnion +{ + /// + /// The active variant value. Either one of the union's concrete payload + /// types, a raw for an unknown + /// variant, or when no variant is set. + /// + public object? Value { get; set; } + + /// Creates a union wrapper with no active variant. + protected AhpUnion() + { + } + + /// Creates a union wrapper around the given variant value. + /// The concrete variant payload. + protected AhpUnion(object? value) + { + Value = value; + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs new file mode 100644 index 00000000..a2b7d609 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs @@ -0,0 +1,37 @@ +// Serializer seam — the pluggable boundary that lets the AHP client use a +// different JSON engine (or layer schema validation on top) without changing +// the client or transport. Hand-written. +#nullable enable + +using System; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Abstracts the JSON engine the AHP client uses to encode outbound payloads +/// and decode inbound frames. The default implementation +/// (SystemTextJsonAhpSerializer, in Microsoft.AgentHostProtocol) +/// is backed by System.Text.Json; alternative implementations may swap the +/// engine or decorate it with JSON-Schema validation against the schemas the +/// repository generates under schema/. +/// +public interface IAhpSerializer +{ + /// Serializes to a JSON string. + string Serialize(T value); + + /// Deserializes a JSON string into . + T Deserialize(string json); + + /// Deserializes UTF-8 JSON bytes into . + T Deserialize(ReadOnlySpan utf8Json); + + /// + /// Decodes a transport frame into a , picking the + /// correct variant (request / notification / success / error) from its shape. + /// + JsonRpcMessage DecodeMessage(TransportMessage message); + + /// Encodes a into a text transport frame. + TransportMessage EncodeMessage(JsonRpcMessage message); +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs new file mode 100644 index 00000000..7527681b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs @@ -0,0 +1,92 @@ +// Hand-written wire helper. Not regenerated by `npm run generate:dotnet`. +#nullable enable + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +/// +/// A wire value that may be either a plain JSON string or an object of the +/// form { "markdown": "..." }. The wrapper preserves which form was +/// decoded so that re-encoding round-trips faithfully. The default (empty) +/// value encodes as the empty string "". +/// +[JsonConverter(typeof(StringOrMarkdownConverter))] +public sealed class StringOrMarkdown +{ + /// + /// Non-null iff the value was decoded from the { "markdown": "..." } + /// object form. + /// + public string? Markdown { get; set; } + + /// Non-null iff the value was decoded from a bare JSON string. + public string? Plain { get; set; } + + /// Creates an empty value (encodes as ""). + public StringOrMarkdown() + { + } + + /// Returns a value that encodes as a bare JSON string. + public static StringOrMarkdown FromPlain(string text) => new() { Plain = text }; + + /// Returns a value that encodes as { "markdown": text }. + public static StringOrMarkdown FromMarkdown(string text) => new() { Markdown = text }; + + /// + /// Returns the underlying text regardless of which form the value was + /// decoded from. Returns the empty string for the empty value. + /// + public string AsText() => Plain ?? Markdown ?? string.Empty; +} + +/// System.Text.Json converter for . +internal sealed class StringOrMarkdownConverter : JsonConverter +{ + /// + public override StringOrMarkdown Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return new StringOrMarkdown(); + case JsonTokenType.String: + return new StringOrMarkdown { Plain = reader.GetString() }; + default: + using (JsonDocument doc = JsonDocument.ParseValue(ref reader)) + { + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("markdown", out JsonElement md) + && md.ValueKind == JsonValueKind.String) + { + return new StringOrMarkdown { Markdown = md.GetString() }; + } + + throw new JsonException("StringOrMarkdown object form missing required 'markdown' field"); + } + } + } + + /// + public override void Write(Utf8JsonWriter writer, StringOrMarkdown value, JsonSerializerOptions options) + { + if (value.Plain is not null) + { + writer.WriteStringValue(value.Plain); + return; + } + + if (value.Markdown is not null) + { + writer.WriteStartObject(); + writer.WriteString("markdown", value.Markdown); + writer.WriteEndObject(); + return; + } + + writer.WriteStringValue(string.Empty); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs new file mode 100644 index 00000000..651b7681 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs @@ -0,0 +1,102 @@ +// Hand-written generic converter shared by every generated discriminated +// union. Not regenerated by `npm run generate:dotnet`. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +/// +/// System.Text.Json converter base for discriminated unions whose variant is +/// selected by a string-valued discriminator field. The generated code +/// supplies the discriminator field name and the wire-value → CLR-type map +/// via a tiny subclass; all of the read/write logic lives here. +/// +/// The generated union wrapper type. +public abstract class UnionConverter : JsonConverter + where T : AhpUnion, new() +{ + private readonly string _discriminator; + private readonly IReadOnlyDictionary _variants; + private readonly bool _allowUnknown; + + /// Creates a union converter. + /// The JSON field that selects the variant. + /// Map from discriminator wire value to CLR payload type. + /// + /// When , an unrecognized discriminator value is + /// preserved as a raw rather than throwing. + /// + protected UnionConverter( + string discriminator, + IReadOnlyDictionary variants, + bool allowUnknown) + { + _discriminator = discriminator; + _variants = variants; + _allowUnknown = allowUnknown; + } + + /// + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + JsonElement root = doc.RootElement; + + string? disc = null; + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(_discriminator, out JsonElement discElement) + && discElement.ValueKind == JsonValueKind.String) + { + disc = discElement.GetString(); + } + + var result = new T(); + if (disc is not null && _variants.TryGetValue(disc, out Type? variantType)) + { + result.Value = root.Deserialize(variantType, options); + } + else if (_allowUnknown) + { + // Preserve the original JSON verbatim for loss-free round-trips. + result.Value = root.Clone(); + } + else + { + throw new JsonException( + $"Unknown {typeof(T).Name} discriminator '{disc}' (field '{_discriminator}')"); + } + + return result; + } + + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + object? inner = value?.Value; + if (inner is null) + { + writer.WriteNullValue(); + return; + } + + if (inner is JsonElement raw) + { + // Unknown variant — emit the preserved JSON exactly as received. + raw.WriteTo(writer); + return; + } + + // Serialize by the runtime type so every property (including the + // variant's own discriminator field) is written. + JsonSerializer.Serialize(writer, inner, inner.GetType(), options); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/WireEnum.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/WireEnum.cs new file mode 100644 index 00000000..fcad71be --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/WireEnum.cs @@ -0,0 +1,93 @@ +// Hand-written support for string-valued protocol enums whose wire form does +// not match the C# member name (e.g. "single-select", "pending-confirmation", +// "session/delta"). Not regenerated by `npm run generate:dotnet`. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Declares the exact wire string for an enum member when it differs from the +/// member's C# name. Consumed by . +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] +public sealed class WireValueAttribute : Attribute +{ + /// The wire string for the annotated enum member. + public string Value { get; } + + /// Creates a . + /// The wire string. + public WireValueAttribute(string value) + { + Value = value; + } +} + +/// +/// System.Text.Json converter that (de)serializes a string-valued protocol +/// enum using the on each member (falling +/// back to the member name when the attribute is absent). +/// +/// The enum type. +public sealed class WireEnumConverter : JsonConverter + where T : struct, Enum +{ + private static readonly Dictionary s_toWire = BuildToWire(); + private static readonly Dictionary s_fromWire = BuildFromWire(); + + private static Dictionary BuildToWire() + { + var map = new Dictionary(); + foreach (FieldInfo field in typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var value = (T)field.GetValue(null)!; + string wire = field.GetCustomAttribute()?.Value ?? field.Name; + map[value] = wire; + } + + return map; + } + + private static Dictionary BuildFromWire() + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (FieldInfo field in typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var value = (T)field.GetValue(null)!; + string wire = field.GetCustomAttribute()?.Value ?? field.Name; + map[wire] = value; + } + + return map; + } + + /// + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? wire = reader.GetString(); + if (wire is not null && s_fromWire.TryGetValue(wire, out T value)) + { + return value; + } + + throw new JsonException($"Unknown {typeof(T).Name} wire value '{wire}'"); + } + + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + if (s_toWire.TryGetValue(value, out string? wire)) + { + writer.WriteStringValue(wire); + return; + } + + throw new JsonException($"Unmapped {typeof(T).Name} value '{value}'"); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/IKeepAliveTransport.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/IKeepAliveTransport.cs new file mode 100644 index 00000000..a93ce505 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/IKeepAliveTransport.cs @@ -0,0 +1,31 @@ +// Optional keep-alive capability for transports that can send protocol-level +// pings. Port of the Swift `AHPKeepAliveTransport` protocol +// (clients/swift/.../Transport/AHPTransport.swift). +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Optional capability for transports that can send protocol-level pings. +/// +/// uses this only when +/// is enabled. Transports that do not support pings can simply not implement this +/// interface; keep-alive is then unavailable for those transports and the client +/// silently skips its ping loop. +/// +/// +public interface IKeepAliveTransport : ITransport +{ + /// + /// Sends a transport-level ping and completes after the matching pong arrives + /// (or throws on timeout / transport failure). Mirrors the Swift + /// AHPKeepAliveTransport.sendPing(timeout:). + /// + /// How long to wait for the matching pong before failing. + /// Cancels the ping wait. + ValueTask SendPingAsync(TimeSpan timeout, CancellationToken cancellationToken = default); +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/ITransport.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/ITransport.cs new file mode 100644 index 00000000..a1e75eb6 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/ITransport.cs @@ -0,0 +1,71 @@ +// Transport seam — the pluggable boundary between the AHP client and the +// underlying byte stream (WebSocket, in-memory pipe, IPC, ...). Hand-written. +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol; + +/// The wire framing of a single transport message. +public enum TransportFrame +{ + /// A UTF-8 text frame (the common case for JSON-RPC). + Text, + + /// A binary frame. + Binary, +} + +/// +/// A single message exchanged over an . A message is +/// either a UTF-8 text frame or a binary frame; the AHP client encodes and +/// decodes JSON-RPC payloads from these frames via an . +/// +public sealed class TransportMessage +{ + private TransportMessage(TransportFrame frame, string? text, ReadOnlyMemory binary) + { + Frame = frame; + Text = text; + Binary = binary; + } + + /// The framing of this message. + public TransportFrame Frame { get; } + + /// The text payload when is . + public string? Text { get; } + + /// The binary payload when is . + public ReadOnlyMemory Binary { get; } + + /// Creates a UTF-8 text message. + public static TransportMessage FromText(string text) => + new(TransportFrame.Text, text ?? throw new ArgumentNullException(nameof(text)), default); + + /// Creates a binary message. + public static TransportMessage FromBinary(ReadOnlyMemory bytes) => + new(TransportFrame.Binary, null, bytes); +} + +/// +/// A bidirectional, ordered, message-framed transport. Implementations are +/// responsible only for moving opaque frames; JSON-RPC encoding lives in the +/// client. A single transport instance is used by exactly one client. +/// +public interface ITransport : IAsyncDisposable +{ + /// Sends a single message. + ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default); + + /// + /// Receives the next message. Implementations transparently handle and skip + /// control frames (ping/pong). Throws when the transport is closed. + /// + ValueTask ReceiveAsync(CancellationToken cancellationToken = default); + + /// Closes the transport gracefully. + ValueTask CloseAsync(CancellationToken cancellationToken = default); +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/TransportClosedException.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/TransportClosedException.cs new file mode 100644 index 00000000..000a85f6 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/TransportClosedException.cs @@ -0,0 +1,29 @@ +// Typed signal for a clean remote close of the transport. +#nullable enable + +using System; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Thrown when the remote peer closes the transport cleanly. +/// Distinct from transport fault exceptions so callers can differentiate +/// between a clean remote close and an I/O error. +/// +public sealed class TransportClosedException : Exception +{ + /// Creates a with a default message. + public TransportClosedException() + : base("The transport was closed by the remote peer.") { } + + /// Creates a with the given message. + /// A human-readable description of the close reason. + public TransportClosedException(string message) + : base(message) { } + + /// Creates a with a message and inner exception. + /// A human-readable description of the close reason. + /// The exception that caused this one. + public TransportClosedException(string message, Exception inner) + : base(message, inner) { } +} diff --git a/clients/dotnet/src/AgentHostProtocol.WebSockets/AgentHostProtocol.WebSockets.csproj b/clients/dotnet/src/AgentHostProtocol.WebSockets/AgentHostProtocol.WebSockets.csproj new file mode 100644 index 00000000..0e4ea113 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.WebSockets/AgentHostProtocol.WebSockets.csproj @@ -0,0 +1,23 @@ + + + + net8.0;net9.0 + Microsoft.AgentHostProtocol.WebSockets + Microsoft.AgentHostProtocol.WebSockets + true + Microsoft.AgentHostProtocol.WebSockets + + A ClientWebSocket-backed ITransport implementation for the Agent Host Protocol .NET client. + Uses only BCL WebSocket APIs (System.Net.WebSockets); no external NuGet dependencies. + + + + + + + + + + + + diff --git a/clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs b/clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs new file mode 100644 index 00000000..449011bd --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs @@ -0,0 +1,223 @@ +// WebSocket-backed ITransport implementation. +// Port of clients/go/ahpws/transport.go, adapted to BCL ClientWebSocket. +// No external NuGet dependencies — uses System.Net.WebSockets only. +#nullable enable + +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; + +namespace Microsoft.AgentHostProtocol.WebSockets; + +/// +/// Options for and +/// . +/// +public sealed class WebSocketTransportOptions +{ + /// + /// Optional callback invoked on the new before + /// it connects. Use it to set request headers, sub-protocols, keep-alive, + /// proxy settings, etc. + /// + public Action? ConfigureSocket { get; set; } + + /// + /// Maximum number of bytes allowed in a single inbound message. + /// A value ≤ 0 means unlimited. Defaults to 32 MiB. + /// + public long MaxMessageBytes { get; set; } = 32L * 1024 * 1024; +} + +/// +/// A implementation backed by . +/// Use to dial, +/// or to wrap +/// an existing connection. +/// +public sealed class WebSocketTransport : ITransport +{ + private readonly ClientWebSocket _ws; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly long _maxMessageBytes; + + // Receive buffer: 64 KiB initial, grows as needed. + private byte[] _receiveBuffer = new byte[64 * 1024]; + + private WebSocketTransport(ClientWebSocket ws, long maxMessageBytes) + { + _ws = ws; + _maxMessageBytes = maxMessageBytes; + } + + // ── Factory methods ─────────────────────────────────────────────────── + + /// + /// Dials (must use ws:// or wss://) and + /// returns a ready-to-use . + /// + /// The WebSocket server URI. + /// Optional configuration; see . + /// Cancellation token for the connect operation. + public static async Task ConnectAsync( + Uri uri, + WebSocketTransportOptions? options = null, + CancellationToken cancellationToken = default) + { + var ws = new ClientWebSocket(); + options?.ConfigureSocket?.Invoke(ws); + await ws.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + var maxBytes = options?.MaxMessageBytes ?? (32L * 1024 * 1024); + return new WebSocketTransport(ws, maxBytes); + } + + /// + /// Wraps an already-connected in a + /// . + /// The transport takes ownership of and disposes it on + /// . + /// + /// A connected . + /// Optional configuration; see . + public static WebSocketTransport FromClientWebSocket(ClientWebSocket ws, WebSocketTransportOptions? options = null) + { + if (ws is null) throw new ArgumentNullException(nameof(ws)); + var maxBytes = options?.MaxMessageBytes ?? (32L * 1024 * 1024); + return new WebSocketTransport(ws, maxBytes); + } + + // ── ITransport ──────────────────────────────────────────────────────── + + /// + public async ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + { + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (message.Frame == TransportFrame.Text) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(message.Text ?? ""); + await _ws.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken) + .ConfigureAwait(false); + } + else + { + var mem = message.Binary; + await _ws.SendAsync( + mem, + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + _sendLock.Release(); + } + } + + /// + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + // Assemble fragments into a complete message. + var builder = new System.IO.MemoryStream(); + WebSocketMessageType msgType = WebSocketMessageType.Text; + + while (true) + { + ValueWebSocketReceiveResult result; + try + { + result = await _ws.ReceiveAsync( + new Memory(_receiveBuffer), + cancellationToken) + .ConfigureAwait(false); + } + catch (WebSocketException ex) + { + throw new Exception($"ahp: websocket closed: {ex.Message}", ex); + } + catch (OperationCanceledException) + { + throw; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + // Perform the closing handshake (best effort). + try + { + await _ws.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "", + CancellationToken.None) + .ConfigureAwait(false); + } + catch { /* best effort */ } + throw new TransportClosedException(); + } + + // Enforce the inbound message size cap. + if (_maxMessageBytes > 0 && (builder.Length + result.Count) > _maxMessageBytes) + { + throw new TransportClosedException( + $"inbound message exceeds {_maxMessageBytes} bytes"); + } + + // Grow receive buffer if needed. + if (result.Count == _receiveBuffer.Length && !result.EndOfMessage) + { + var bigger = new byte[_receiveBuffer.Length * 2]; + _receiveBuffer = bigger; + } + + builder.Write(_receiveBuffer, 0, result.Count); + msgType = result.MessageType; + + if (result.EndOfMessage) + break; + } + + var bytes = builder.ToArray(); + + if (msgType == WebSocketMessageType.Binary) + return TransportMessage.FromBinary(bytes); + + return TransportMessage.FromText(System.Text.Encoding.UTF8.GetString(bytes)); + } + + /// + public async ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + if (_ws.State == WebSocketState.Open + || _ws.State == WebSocketState.CloseReceived + || _ws.State == WebSocketState.CloseSent) + { + try + { + await _ws.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "", + cancellationToken) + .ConfigureAwait(false); + } + catch (WebSocketException) { /* best effort — state race */ } + catch (InvalidOperationException) { /* best effort — state race */ } + } + } + + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None).ConfigureAwait(false); + _ws.Dispose(); + _sendLock.Dispose(); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj b/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj new file mode 100644 index 00000000..7896c19f --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj @@ -0,0 +1,25 @@ + + + + Microsoft.AgentHostProtocol + net8.0;net9.0 + Microsoft.AgentHostProtocol + true + Microsoft.AgentHostProtocol + + The Agent Host Protocol (AHP) client for .NET: an async JSON-RPC client, + the pure state reducers, the default System.Text.Json serializer, and the + multi-host runtime. Bring your own transport (see + Microsoft.AgentHostProtocol.WebSockets for a ClientWebSocket-based one). + + + + + + + + + + + + diff --git a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs new file mode 100644 index 00000000..ce3ebbb8 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs @@ -0,0 +1,1091 @@ +// Async JSON-RPC client over ITransport + IAhpSerializer. +// Faithful port of clients/go/ahp/client.go. +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol; + +// ─── Configuration ──────────────────────────────────────────────────────────── + +/// +/// Optional transport liveness policy for an . Port of the +/// Swift AHPKeepAlivePolicy (clients/swift/.../AHPClientConfig.swift). +/// +/// Keep-alive is disabled by default. When enabled, the client sends periodic +/// transport-level pings if the configured transport implements +/// ; ping failures are treated as transport +/// failures and tear the client down. +/// +/// +public sealed class KeepAlivePolicy +{ + private KeepAlivePolicy(bool isEnabled, TimeSpan interval, TimeSpan timeout) + { + IsEnabled = isEnabled; + Interval = interval; + Timeout = timeout; + } + + /// Whether the keep-alive ping loop runs. + public bool IsEnabled { get; } + + /// How often a ping is sent (only meaningful when ). + public TimeSpan Interval { get; } + + /// How long each ping waits for its pong before failing. + public TimeSpan Timeout { get; } + + /// Do not run a keep-alive task. Mirrors Swift .disabled. + public static KeepAlivePolicy Disabled { get; } = + new(isEnabled: false, interval: TimeSpan.Zero, timeout: TimeSpan.Zero); + + /// + /// Periodically send a transport-level ping. Mirrors Swift + /// .ping(interval:timeout:). + /// + public static KeepAlivePolicy Ping(TimeSpan interval, TimeSpan timeout) => + new(isEnabled: true, interval: interval, timeout: timeout); + + /// + /// Convenience for the common WebSocket ping policy (30 s interval, 5 s + /// timeout by default). Mirrors Swift .enabled(interval:timeout:). + /// + public static KeepAlivePolicy Enabled(TimeSpan? interval = null, TimeSpan? timeout = null) => + Ping(interval ?? TimeSpan.FromSeconds(30), timeout ?? TimeSpan.FromSeconds(5)); +} + +/// Tuning knobs for an . +public sealed class ClientConfig +{ + /// + /// How long waits for a + /// response. Zero disables the timeout. Defaults to 30 seconds. + /// + public TimeSpan DefaultRequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Capacity of each subscription's event channel. Excess events are dropped + /// on a full channel (mirrors Go's SubscriptionBuffer). Defaults to 256. + /// + public int SubscriptionBufferCapacity { get; set; } = 256; + + /// + /// Optional transport liveness policy. Defaults to + /// . Mirrors the Swift + /// AHPClientConfig.keepAlive field. + /// + public KeepAlivePolicy KeepAlive { get; set; } = KeepAlivePolicy.Disabled; + + /// Returns a config with sensible defaults (30 s timeout, 256-message buffer). + public static ClientConfig Default { get; } = new(); +} + +// ─── Connection state ────────────────────────────────────────────────────────── + +/// +/// Connection state observable on and the +/// multicast stream. Port of the +/// Swift ConnectionState enum (clients/swift/.../AHPClientEvents.swift). +/// +public enum ConnectionState +{ + /// No active receive loop; the transport may or may not be open. + Disconnected, + + /// A connection attempt is in progress. + Connecting, + + /// The receive loop is running; the transport is treated as live. + Connected, +} + +// ─── Server-initiated request handling ───────────────────────────────────────── + +/// +/// Handles a server-initiated JSON-RPC request. Return the result object to +/// reply with success; throw to reply with that +/// JSON-RPC error. Receives the raw method name and the raw params element. +/// +/// The JSON-RPC method the server invoked. +/// The raw params element, or if absent. +/// The result object to serialize into the success reply (may be null). +public delegate Task ServerRequestHandler(string method, JsonElement? @params); + +// ─── AhpClient ──────────────────────────────────────────────────────────────── + +/// +/// Async JSON-RPC client over a pluggable . +/// +/// Create with which spawns a background read loop. +/// All public methods are safe to call from multiple threads. +/// +/// +public sealed class AhpClient : IAsyncDisposable +{ + // ── State that lives for the client lifetime ────────────────────────── + + private readonly ITransport _transport; + private readonly IAhpSerializer _serializer; + private readonly ClientConfig _cfg; + + // Outbound queue (reader goroutine in Go; here driven by a Task). + private readonly System.Threading.Channels.Channel _outbound; + + // In-flight request correlation keyed by JSON-RPC id. + private readonly ConcurrentDictionary> _pending = new(); + + // Per-URI subscription fan-out. + private readonly Gate _subsLock = new(); + private readonly Dictionary> _subscriptions = new(); + private readonly List _eventListeners = new(); + + // Multicast connection-state fan-out. Guarded by `_subsLock` (same lock as the + // event listeners — every fan-out path already takes it). + private readonly List _stateListeners = new(); + + // Current connection state. `volatile` supplies the visibility a lock would + // otherwise give for the lock-free `ConnectionState` reader. + private volatile ConnectionStateBox _connectionState = new(AgentHostProtocol.ConnectionState.Connected); + + // Boxes the enum so it can live behind a `volatile` field (enums aren't valid + // `volatile` targets directly). + private sealed class ConnectionStateBox + { + public ConnectionState Value { get; } + public ConnectionStateBox(ConnectionState value) => Value = value; + } + + // Keep-alive ping loop (null when disabled or the transport isn't ping-capable). + private readonly CancellationTokenSource _keepAliveCts = new(); + private readonly Task? _keepAliveTask; + + // Monotonically incrementing counters (no lock needed — Interlocked). + private ulong _nextId = 1; + private long _nextClientSeq = 1; + + // ── Test-only accessors (InternalsVisibleTo the test assembly) ───────── + // Mirror the Swift client's `_pendingCount()` test hook so the cancellation + // parity tests can observe the real pending-request bookkeeping (1 -> 0 on a + // cancelled in-flight request) without widening the public API. + + /// The number of in-flight requests awaiting a response. + internal int PendingRequestCount => _pending.Count; + + /// + /// The next JSON-RPC request id that would be minted. Lets the fast-fail + /// parity test prove a pre-cancelled request did NOT mint an id (the counter + /// is unchanged). + /// + internal ulong NextRequestId => Volatile.Read(ref _nextId); + + // Optional handler for server-initiated requests. Published reference, read + // lock-free; `volatile` supplies the visibility a lock would otherwise give. + private volatile ServerRequestHandler? _serverRequestHandler; + + // Lifecycle + private readonly TaskCompletionSource _doneTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _shutdownStarted; // 0 = running, 1 = shut down + private Exception? _closeErr; + + private readonly Task _readerTask; + private readonly Task _writerTask; + + // ── Inner types ─────────────────────────────────────────────────────── + + private sealed class OutboundMessage + { + public JsonRpcMessage Message { get; } + public TaskCompletionSource? Sent { get; } + + public OutboundMessage(JsonRpcMessage message, TaskCompletionSource? sent = null) + { + Message = message; + Sent = sent; + } + } + + // ── Constructor / factory ───────────────────────────────────────────── + + private AhpClient(ITransport transport, ClientConfig cfg, IAhpSerializer serializer) + { + _transport = transport; + _cfg = cfg; + _serializer = serializer; + _outbound = System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(64) + { + FullMode = System.Threading.Channels.BoundedChannelFullMode.Wait, + }); + _readerTask = Task.Run(RunReaderAsync); + _writerTask = Task.Run(RunWriterAsync); + + // Start the keep-alive ping loop iff a ping policy is configured AND the + // transport advertises the ping capability. Mirrors the Swift + // `startKeepAliveIfNeeded()` guard (`case .ping` + `as? AHPKeepAliveTransport`). + if (_cfg.KeepAlive.IsEnabled && _transport is IKeepAliveTransport pingTransport) + { + _keepAliveTask = Task.Run(() => RunKeepAliveAsync(pingTransport)); + } + } + + /// + /// Wires to a new and + /// starts the background reader / writer tasks. The client owns the transport + /// from this point. + /// + public static AhpClient Connect( + ITransport transport, + ClientConfig? config = null, + IAhpSerializer? serializer = null) + { + var cfg = config ?? ClientConfig.Default; + if (cfg.SubscriptionBufferCapacity <= 0) cfg.SubscriptionBufferCapacity = 256; + return new AhpClient(transport, cfg, serializer ?? SystemTextJsonAhpSerializer.Default); + } + + /// + /// Wires to a new and + /// starts the background reader / writer tasks. The client owns the transport + /// from this point. + /// + /// + /// Kept for source compatibility. Prefer the synchronous factory. + /// + public static Task ConnectAsync( + ITransport transport, + ClientConfig? config = null, + IAhpSerializer? serializer = null) + => Task.FromResult(Connect(transport, config, serializer)); + + // ── Lifecycle ───────────────────────────────────────────────────────── + + /// + /// A that completes once the client begins teardown (either + /// via or a transport failure). + /// + public Task Completion => _doneTcs.Task; + + /// + /// The first error that triggered teardown, or if the + /// client is still running or was shut down cleanly. + /// + public Exception? Error => Volatile.Read(ref _closeErr); + + /// + /// Gracefully tears down the client. In-flight requests complete with + /// . Subscriptions and event streams are + /// closed. The underlying transport is closed too. + /// Safe to call multiple times. + /// + public async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + await ShutdownWithErrorAsync(null).ConfigureAwait(false); + // Wait for both background tasks to exit. + await Task.WhenAll(_readerTask, _writerTask).WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask DisposeAsync() + { + await ShutdownAsync().ConfigureAwait(false); + } + + /// + /// Centralised idempotent teardown path. All shutdown paths funnel through here. + /// + private async Task ShutdownWithErrorAsync(Exception? cause) + { + if (Interlocked.CompareExchange(ref _shutdownStarted, 1, 0) != 0) + { + return; // Already shutting down. + } + + Volatile.Write(ref _closeErr, cause); + + // Signal the done task so Done-waiters unblock. + _doneTcs.TrySetResult(); + + // Stop the keep-alive loop (no more pings once we're tearing down). Mirrors + // the Swift `keepAliveTask?.cancel()` in both shutdown and failure paths. + _keepAliveCts.Cancel(); + + // Close the transport so any blocked ReceiveAsync unblocks. + using var shutdownCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + await _transport.CloseAsync(shutdownCts.Token).ConfigureAwait(false); + } + catch { /* best effort */ } + + // Complete the outbound channel so the writer exits. + _outbound.Writer.TryComplete(); + + // Fail every in-flight request. + var shutdownEx = cause is null + ? new AhpClientClosedException() + : new AhpClientClosedException($"ahp: client shut down: {cause.Message}"); + + foreach (var kv in _pending) + { + if (_pending.TryRemove(kv.Key, out var tcs)) + { + tcs.TrySetException(shutdownEx); + } + } + + // Close every subscription and listener. + List allSubs; + List allListeners; + lock (_subsLock) + { + allSubs = new List(); + foreach (var list in _subscriptions.Values) + allSubs.AddRange(list); + _subscriptions.Clear(); + + allListeners = new List(_eventListeners); + _eventListeners.Clear(); + } + foreach (var sub in allSubs) sub.Close(); + foreach (var lst in allListeners) lst.Close(); + + // Fan out a final `.Disconnected` transition, then finish the state-change + // streams. Mirrors the Swift shutdown tail: `transition(to: .disconnected)` + // immediately followed by `finishAllStateListeners()`. State streams (unlike + // the event taps) deliver this terminal transition before completing, so a + // consumer awaiting the stream sees `Disconnected` as the last item. + Transition(AgentHostProtocol.ConnectionState.Disconnected); + List allStateListeners; + lock (_subsLock) + { + allStateListeners = new List(_stateListeners); + _stateListeners.Clear(); + } + foreach (var st in allStateListeners) st.Close(); + } + + // ── Connection state ────────────────────────────────────────────────── + + /// + /// The current connection state, readable synchronously. Mirrors the Swift + /// connectionState property. The client is + /// from construction (the read/write loops start immediately in ) + /// and transitions to on shutdown or + /// transport failure. + /// + public ConnectionState ConnectionState => _connectionState.Value; + + /// + /// Returns a fresh multicast of future + /// transitions. Mirrors the Swift + /// stateChanges stream: each call returns an independent stream that + /// delivers only transitions occurring after attachment; the current value is + /// available synchronously via . + /// + public StateChangeStream CreateStateChangeStream() + { + var stream = new StateChangeStream(Math.Max(8, _cfg.SubscriptionBufferCapacity)); + lock (_subsLock) + { + _stateListeners.Add(stream); + } + return stream; + } + + /// + /// Records a new connection state and fans it out to every attached + /// . Mirrors the Swift transition(to:). + /// Idempotent on repeated identical states is NOT enforced (Swift fans out on + /// every call); callers transition only on real edges. + /// + private void Transition(ConnectionState newState) + { + _connectionState = new ConnectionStateBox(newState); + List listeners; + lock (_subsLock) + { + listeners = new List(_stateListeners); + } + foreach (var st in listeners) st.TrySend(newState); + } + + // ── Keep-alive ──────────────────────────────────────────────────────── + + /// + /// The keep-alive ping loop. Sleeps for the configured interval, then sends a + /// transport-level ping; a ping failure is treated as a transport failure and + /// tears the client down. Port of the Swift keepAliveTask loop in + /// startKeepAliveIfNeeded(). + /// + private async Task RunKeepAliveAsync(IKeepAliveTransport pingTransport) + { + var policy = _cfg.KeepAlive; + var ct = _keepAliveCts.Token; + try + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(policy.Interval, ct).ConfigureAwait(false); + if (ct.IsCancellationRequested) return; + await pingTransport.SendPingAsync(policy.Timeout, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Normal teardown — the cancellation came from our own shutdown path. + } + catch (Exception ex) + { + // A ping failure (or pong timeout) is a transport failure: tear down + // exactly as the receive/writer loops do on error. Mirrors the Swift + // `handleTransportFailure(error)` call from the keep-alive loop. + await ShutdownWithErrorAsync( + new Exception($"ahp: keep-alive ping: {ex.Message}", ex)).ConfigureAwait(false); + } + } + + // ── Writer loop ─────────────────────────────────────────────────────── + + private async Task RunWriterAsync() + { + try + { + await foreach (var item in _outbound.Reader.ReadAllAsync().ConfigureAwait(false)) + { + var frame = _serializer.EncodeMessage(item.Message); + try + { + await _transport.SendAsync(frame).ConfigureAwait(false); + item.Sent?.TrySetResult(true); + } + catch (Exception ex) + { + item.Sent?.TrySetException(ex); + await ShutdownWithErrorAsync(new Exception($"ahp: transport send: {ex.Message}", ex)).ConfigureAwait(false); + return; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await ShutdownWithErrorAsync(new Exception($"ahp: writer: {ex.Message}", ex)).ConfigureAwait(false); + } + } + + // ── Reader loop ─────────────────────────────────────────────────────── + + private async Task RunReaderAsync() + { + try + { + while (true) + { + if (Volatile.Read(ref _shutdownStarted) == 1) return; + + TransportMessage frame; + try + { + frame = await _transport.ReceiveAsync().ConfigureAwait(false); + } + catch (TransportClosedException) + { + // A clean remote close is not an error: shut down without a cause. + await ShutdownWithErrorAsync(null).ConfigureAwait(false); + return; + } + catch (Exception ex) + { + await ShutdownWithErrorAsync(new Exception($"ahp: transport recv: {ex.Message}", ex)).ConfigureAwait(false); + return; + } + + JsonRpcMessage msg; + try + { + msg = _serializer.DecodeMessage(frame); + } + catch + { + // Skip malformed frames; protocol resync is the server's responsibility. + continue; + } + + Dispatch(msg); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await ShutdownWithErrorAsync(new Exception($"ahp: reader: {ex.Message}", ex)).ConfigureAwait(false); + } + } + + // ── Dispatch ────────────────────────────────────────────────────────── + + private void Dispatch(JsonRpcMessage msg) + { + if (msg.SuccessResponse is not null) + { + Deliver(msg.SuccessResponse.Id, msg.SuccessResponse.Result, null); + } + else if (msg.ErrorResponse is not null) + { + var err = msg.ErrorResponse.Error; + Deliver(msg.ErrorResponse.Id, default, new AhpRpcException(err.Code, err.Message, err.Data)); + } + else if (msg.Notification is not null) + { + HandleNotification(msg.Notification); + } + else if (msg.Request is not null) + { + // Fire-and-forget: server-initiated request. Reply async so the reader + // loop is never blocked by handler work. (Lifts the v0.1 "drop server + // requests" limitation; mirrors the TS client's handleServerRequest.) + _ = HandleServerRequestAsync(msg.Request); + } + } + + private void Deliver(ulong id, JsonElement result, AhpRpcException? rpcError) + { + if (_pending.TryRemove(id, out var tcs)) + { + if (rpcError is not null) + tcs.TrySetException(rpcError); + else + tcs.TrySetResult(result); + } + } + + // ── Server-initiated requests ───────────────────────────────────────── + + /// + /// Installs a handler for server-initiated requests. If none is installed, the + /// client auto-replies MethodNotFound so the server does not leak a + /// pending request. Pass to clear. + /// + public void SetServerRequestHandler(ServerRequestHandler? handler) => _serverRequestHandler = handler; + + /// + /// Replies to an inbound server-initiated request: MethodNotFound if no + /// handler is installed, otherwise the handler's result (or its thrown error). + /// Mirrors the TS client's handleServerRequest. + /// + private async Task HandleServerRequestAsync(JsonRpcRequest req) + { + var handler = _serverRequestHandler; + if (handler is null) + { + await ReplyErrorAsync(req.Id, JsonRpcErrorCodes.MethodNotFound, + $"no handler for server method \"{req.Method}\"").ConfigureAwait(false); + return; + } + try + { + var result = await handler(req.Method, req.Params).ConfigureAwait(false); + JsonElement resultEl; + if (result is null) + { + using var doc = JsonDocument.Parse("null"); + resultEl = doc.RootElement.Clone(); + } + else + { + using var doc = JsonDocument.Parse(_serializer.Serialize(result)); + resultEl = doc.RootElement.Clone(); + } + await ReplyResultAsync(req.Id, resultEl).ConfigureAwait(false); + } + catch (AhpRpcException rpc) + { + await ReplyErrorAsync(req.Id, rpc.Code, rpc.Message).ConfigureAwait(false); + } + catch (Exception ex) + { + await ReplyErrorAsync(req.Id, JsonRpcErrorCodes.InternalError, ex.Message).ConfigureAwait(false); + } + } + + private Task ReplyResultAsync(ulong id, JsonElement result) => + EnqueueReplyAsync(new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse { Id = id, Result = result }, + }); + + private Task ReplyErrorAsync(ulong id, int code, string message) => + EnqueueReplyAsync(new JsonRpcMessage + { + ErrorResponse = new JsonRpcErrorResponse + { + Id = id, + Error = new JsonRpcErrorObject { Code = code, Message = message }, + }, + }); + + // Enqueue a reply frame on the existing outbound channel. Best-effort: if the + // client is shutting down, the reply is dropped (the transport is gone anyway). + private async Task EnqueueReplyAsync(JsonRpcMessage msg) + { + if (Volatile.Read(ref _shutdownStarted) == 1) return; + try + { + await _outbound.Writer.WriteAsync(new OutboundMessage(msg)).ConfigureAwait(false); + } + catch { /* shutting down — best effort */ } + } + + private void HandleNotification(JsonRpcNotification n) + { + if (n.Params is null) return; + var paramsEl = n.Params.Value; + + switch (n.Method) + { + case "action": + { + ActionEnvelope env; + try { env = _serializer.Deserialize(paramsEl.GetRawText()); } + catch { return; } + FanOut(env.Channel, new SubscriptionEventAction(env)); + break; + } + case "root/sessionAdded": + { + SessionAddedParams p; + try { p = _serializer.Deserialize(paramsEl.GetRawText()); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventSessionAdded(p)); + break; + } + case "root/sessionRemoved": + { + SessionRemovedParams p; + try { p = _serializer.Deserialize(paramsEl.GetRawText()); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventSessionRemoved(p)); + break; + } + case "root/sessionSummaryChanged": + { + SessionSummaryChangedParams p; + try { p = _serializer.Deserialize(paramsEl.GetRawText()); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventSessionSummaryChanged(p)); + break; + } + case "auth/required": + { + AuthRequiredParams p; + try { p = _serializer.Deserialize(paramsEl.GetRawText()); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventAuthRequired(p)); + break; + } + } + } + + private void FanOut(string channel, SubscriptionEvent ev) + { + List subs; + List listeners; + lock (_subsLock) + { + subs = _subscriptions.TryGetValue(channel, out var list) + ? new List(list) + : new List(); + listeners = new List(_eventListeners); + } + foreach (var sub in subs) sub.TrySend(ev); + foreach (var lst in listeners) lst.TrySend(new ClientEvent(channel, ev)); + } + + // ── Request / Notify ────────────────────────────────────────────────── + + /// + /// Sends a JSON-RPC request and decodes the result. Applies the configured + /// default timeout whenever is + /// positive, composing it with any caller-supplied cancellation token. + /// + public async Task RequestAsync( + string method, + TParams @params, + CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _shutdownStarted) == 1) + throw new AhpClientClosedException(); + + // Fast-fail when the caller's token is already cancelled. Mirrors the + // Swift client's `Task.checkCancellation()` at the top of `request`: + // avoid minting a request id and pushing wire bytes for a request whose + // result would be thrown away immediately. Must run BEFORE the id is + // minted and the pending entry is registered. + cancellationToken.ThrowIfCancellationRequested(); + + var id = Interlocked.Increment(ref _nextId) - 1; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + + // Re-check shutdown after inserting into _pending so a request registered + // during shutdown cannot hang. + if (Volatile.Read(ref _shutdownStarted) == 1) + { + _pending.TryRemove(id, out _); + throw new AhpClientClosedException(); + } + + JsonElement? paramsEl; + if (@params is null) + { + paramsEl = null; + } + else + { + using var doc = JsonDocument.Parse(_serializer.Serialize(@params)); + paramsEl = doc.RootElement.Clone(); + } + + var req = new JsonRpcMessage + { + Request = new JsonRpcRequest + { + Id = id, + Method = method, + Params = paramsEl, + } + }; + + try + { + await SendMessageAsync(req, cancellationToken).ConfigureAwait(false); + } + catch + { + _pending.TryRemove(id, out _); + throw; + } + + // Always apply the configured default timeout when positive, composing it + // with any caller-supplied cancellation token. + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (_cfg.DefaultRequestTimeout > TimeSpan.Zero) + linkedCts.CancelAfter(_cfg.DefaultRequestTimeout); + + try + { + var resultEl = await tcs.Task.WaitAsync(linkedCts.Token).ConfigureAwait(false); + var json = resultEl.GetRawText(); + if (json == "null" || string.IsNullOrEmpty(json)) + return default!; + return _serializer.Deserialize(json); + } + catch (OperationCanceledException) + { + _pending.TryRemove(id, out _); + throw; + } + catch (AhpRpcException) + { + throw; + } + catch (AhpClientClosedException) + { + throw; + } + } + + /// + /// Sends a JSON-RPC notification (fire-and-forget). + /// + public async Task NotifyAsync( + string method, + TParams @params, + CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _shutdownStarted) == 1) + throw new AhpClientClosedException(); + + JsonElement? paramsEl; + if (@params is null) + { + paramsEl = null; + } + else + { + using var doc = JsonDocument.Parse(_serializer.Serialize(@params)); + paramsEl = doc.RootElement.Clone(); + } + + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = method, + Params = paramsEl, + } + }; + + await SendMessageAsync(notif, cancellationToken).ConfigureAwait(false); + } + + private async Task SendMessageAsync(JsonRpcMessage msg, CancellationToken cancellationToken) + { + var sentTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var item = new OutboundMessage(msg, sentTcs); + + try + { + await _outbound.Writer.WriteAsync(item, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (Volatile.Read(ref _shutdownStarted) == 1) + throw new AhpClientClosedException(); + throw new AhpTransportException("io", null, ex); + } + + // Wait for the writer goroutine to actually send the frame. + await sentTcs.Task.ConfigureAwait(false); + } + + // ── Protocol surface ────────────────────────────────────────────────── + + /// Issues the initialize handshake. + public Task InitializeAsync( + string clientId, + IReadOnlyList? protocolVersions = null, + IReadOnlyList? initialSubscriptions = null, + CancellationToken cancellationToken = default) + { + var versions = protocolVersions is not null + ? new System.Collections.Generic.List(protocolVersions) + : new System.Collections.Generic.List(ProtocolVersion.Supported); + + var @params = new InitializeParams + { + Channel = ProtocolVersion.RootResourceUri, + ProtocolVersions = versions, + ClientId = clientId, + InitialSubscriptions = initialSubscriptions is { Count: > 0 } + ? new System.Collections.Generic.List(initialSubscriptions) + : null, + }; + + return RequestAsync("initialize", @params, cancellationToken); + } + + /// Re-establishes a dropped connection via the reconnect flow. + public Task ReconnectAsync( + string clientId, + long lastSeenServerSeq, + IReadOnlyList? subscriptions = null, + CancellationToken cancellationToken = default) + { + var @params = new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, + ClientId = clientId, + LastSeenServerSeq = lastSeenServerSeq, + Subscriptions = subscriptions is not null + ? new System.Collections.Generic.List(subscriptions) + : new System.Collections.Generic.List(), + }; + + return RequestAsync("reconnect", @params, cancellationToken); + } + + /// + /// Sends a subscribe request and returns the initial snapshot plus a + /// per-URI handle. + /// + public async Task<(SubscribeResult Result, Subscription Sub)> SubscribeAsync( + string uri, + CancellationToken cancellationToken = default) + { + var sub = AttachSubscription(uri); + try + { + var result = await RequestAsync( + "subscribe", new SubscribeParams { Channel = uri }, cancellationToken) + .ConfigureAwait(false); + return (result, sub); + } + catch + { + sub.Close(); + throw; + } + } + + /// + /// Returns a local for without + /// sending a subscribe request. Useful when the URI was included in + /// initialSubscriptions during . + /// + public Subscription AttachSubscription(string uri) + { + var sub = new Subscription(uri, _cfg.SubscriptionBufferCapacity); + lock (_subsLock) + { + if (!_subscriptions.TryGetValue(uri, out var list)) + { + list = new List(); + _subscriptions[uri] = list; + } + list.Add(sub); + } + return sub; + } + + /// + /// Sends an unsubscribe notification and drops every local + /// for . + /// + public async Task UnsubscribeAsync(string uri, CancellationToken cancellationToken = default) + { + List subs; + lock (_subsLock) + { + if (!_subscriptions.Remove(uri, out subs!)) + subs = new List(); + } + foreach (var sub in subs) sub.Close(); + await NotifyAsync("unsubscribe", new UnsubscribeParams { Channel = uri }, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Fires a write-ahead dispatchAction notification. + /// + /// Channel URI the action targets. + /// The action to dispatch. + /// + /// Optional caller-owned sequence number. When null (the default), the + /// next auto-incrementing client sequence is assigned. When supplied, that + /// exact value is sent on the wire and recorded on the returned handle — for + /// an app-level outbox that needs stable sequence numbers across + /// reconnect/replay. To keep later auto-assigned numbers from colliding, the + /// internal counter is advanced past an explicit value that is at or beyond it + /// (mirroring Swift's dispatch(clientSeq:)). + /// + /// Cancels the send. + public async Task DispatchAsync( + string channel, + StateAction action, + long? clientSeq = null, + CancellationToken cancellationToken = default) + { + long seq; + if (clientSeq is { } explicitSeq) + { + seq = explicitSeq; + // Advance _nextClientSeq to explicitSeq + 1 if the explicit value is at + // or beyond the current counter, so a subsequent auto-assigned dispatch + // won't reuse this number. CAS loop keeps this race-free under + // concurrent dispatchers (mirrors Swift's `if clientSeq >= nextClientSeq + // { nextClientSeq = clientSeq + 1 }`, done atomically). + while (true) + { + var current = Interlocked.Read(ref _nextClientSeq); + if (explicitSeq < current) break; // counter already ahead + var desired = explicitSeq + 1; + if (Interlocked.CompareExchange(ref _nextClientSeq, desired, current) == current) break; + } + } + else + { + seq = Interlocked.Increment(ref _nextClientSeq) - 1; + } + + await NotifyAsync("dispatchAction", new DispatchActionParams + { + Channel = channel, + ClientSeq = seq, + Action = action, + }, cancellationToken).ConfigureAwait(false); + return new DispatchHandle(seq); + } + + /// + /// Returns a new top-level that receives every + /// inbound event from this client, tagged with the channel URI. Multiple + /// streams may exist concurrently. + /// + public EventStream CreateEventStream() + { + var stream = new EventStream(_cfg.SubscriptionBufferCapacity); + lock (_subsLock) + { + _eventListeners.Add(stream); + } + return stream; + } +} + +// ─── Connection-state stream ──────────────────────────────────────────────────── + +/// +/// A multicast stream of transitions, returned by +/// . Port of the Swift +/// stateChanges AsyncStream. +/// +/// Each stream delivers only transitions that occur after it was created; the +/// current value is available synchronously via . +/// On shutdown the client fans out a terminal +/// transition and then completes the stream, so a consumer draining the stream sees +/// as the last item. +/// +/// +public sealed class StateChangeStream : IDisposable +{ + private readonly System.Threading.Channels.Channel _channel; + private int _closed; + + /// Creates a new state-change stream. + internal StateChangeStream(int bufferCapacity) + { + _channel = System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(bufferCapacity) + { + FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest, + SingleReader = false, + SingleWriter = false, + }); + } + + /// + /// The reader side of the stream. Read from this to receive + /// transitions as they occur. + /// + public System.Threading.Channels.ChannelReader States => _channel.Reader; + + /// Stops the stream. Safe to call multiple times. + public void Close() + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) == 0) + { + _channel.Writer.TryComplete(); + } + } + + /// + public void Dispose() => Close(); + + /// + /// Attempts to deliver a transition. Drops it on a full channel (overflow + /// protection mirrors the event/subscription TrySend). + /// + internal void TrySend(ConnectionState state) + { + if (Volatile.Read(ref _closed) == 1) return; + _channel.Writer.TryWrite(state); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs b/clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs new file mode 100644 index 00000000..1d93d791 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +// Allow the test project to exercise internal members (e.g. the reconnect +// backoff calculation) without widening the public API surface. +[assembly: InternalsVisibleTo("AgentHostProtocol.Tests")] diff --git a/clients/dotnet/src/AgentHostProtocol/Errors.cs b/clients/dotnet/src/AgentHostProtocol/Errors.cs new file mode 100644 index 00000000..e83e7993 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Errors.cs @@ -0,0 +1,100 @@ +// Client error hierarchy — port of the Go client's error.go. +// Mirrors: ahp/error.go (TransportError, RPCError, UnknownSubscriptionError, +// ErrClosed, ErrShutdown, ErrSequenceGap). +#nullable enable + +using System; +using System.Text.Json; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Base exception for all Agent Host Protocol client errors. +/// +public abstract class AhpException : Exception +{ + /// + protected AhpException(string message) : base(message) { } + + /// + protected AhpException(string message, Exception? inner) : base(message, inner) { } +} + +/// +/// Thrown by implementations when the underlying +/// connection experiences a transport-level fault. +/// +public sealed class AhpTransportException : AhpException +{ + /// + /// Classifies the failure: "closed", "io", or "protocol". + /// Mirrors the Go TransportError.Kind field. + /// + public string Kind { get; } + + /// Creates a transport exception. + public AhpTransportException(string kind, string? message = null, Exception? inner = null) + : base(message ?? $"ahp: transport {kind}", inner) + { + Kind = kind; + } +} + +/// +/// Thrown when a JSON-RPC request completes with an error response from the server. +/// +public sealed class AhpRpcException : AhpException +{ + /// The JSON-RPC error code. + public int Code { get; } + + /// The JSON-RPC error data, if present. + public JsonElement? ErrorData { get; } + + /// Creates an RPC exception from the server error response. + public AhpRpcException(int code, string message, JsonElement? data = null) + : base($"ahp: rpc error {code}: {message}") + { + Code = code; + ErrorData = data; + } +} + +/// +/// Thrown by methods when the client (or its +/// background driver) has been shut down. +/// +public sealed class AhpClientClosedException : AhpException +{ + /// Creates a client-closed exception. + public AhpClientClosedException(string? message = null) + : base(message ?? "ahp: client shut down") { } +} + +/// +/// Thrown when an action envelope arrives out of sequence and the client +/// cannot reconcile without a new snapshot. The caller should resubscribe. +/// +public sealed class AhpSequenceGapException : AhpException +{ + /// Creates a sequence-gap exception. + public AhpSequenceGapException() + : base("ahp: sequence gap detected; resubscribe required") { } +} + +/// +/// Thrown by when the URI is not +/// tracked by this client. +/// +public sealed class UnknownSubscriptionException : AhpException +{ + /// The URI that was not found. + public string Uri { get; } + + /// Creates an unknown-subscription exception. + public UnknownSubscriptionException(string uri) + : base($"ahp: no such subscription: {uri}") + { + Uri = uri; + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/GlobalUsings.cs b/clients/dotnet/src/AgentHostProtocol/GlobalUsings.cs new file mode 100644 index 00000000..bc92127c --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Conditional lock primitive: on .NET 9+ the dedicated System.Threading.Lock +// is ~25% faster under contention than Monitor; on net8.0 we fall back to a +// plain object (classic Monitor). The `lock (gate) { ... }` statements are +// identical either way — only the field's declared type changes. See +// docs/decisions/sync.md. +#if NET9_0_OR_GREATER +global using Gate = System.Threading.Lock; +#else +global using Gate = System.Object; +#endif diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs new file mode 100644 index 00000000..86244ace --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs @@ -0,0 +1,174 @@ +// Filesystem-backed IClientIdStore that survives process restarts. +// Faithful port of clients/swift/.../Hosts/ClientIdStore.swift (FileClientIdStore). +// +// One file per host id under a configurable directory; writes are atomic +// (temp file + File.Move overwrite, atomic on the same volume) and best-effort +// restrict permissions to owner-read/write on Unix so the persisted ids aren't +// world-readable. Per-store mutations are serialised through a SemaphoreSlim +// (mirroring Swift's `actor Storage`) so concurrent load/store calls from +// different hosts don't race on the directory's contents. +#nullable enable + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// Filesystem-backed that survives process +/// restarts. Stores one <encoded-host-id>.clientid file per host +/// under ; writes are atomic and best-effort restricted +/// to owner-only permissions on Unix. Mirrors Swift's FileClientIdStore. +/// +/// +/// For the highest-security profile on Apple platforms, wrap a keychain-backed +/// implementation of instead — this store is a +/// reasonable default for desktops, command-line tools, and development builds: +/// it provides persistence without depending on a platform secret store. +/// The directory is created on first write if it doesn't already exist; +/// filenames are derived from each host id via a percent-encoding helper so +/// arbitrary strings (including :, /, etc.) +/// map to safe filesystem paths. +/// +public sealed class FileClientIdStore : IClientIdStore +{ + // Serialises mutations across hosts (mirrors Swift's `actor Storage`). + private readonly SemaphoreSlim _gate = new(1, 1); + + /// The directory this store persists client-id files under. + public string Directory { get; } + + /// + /// Builds a store rooted at . The directory is + /// created when needed; the caller is responsible for picking a location + /// the process can write to (e.g. an application-support directory on + /// desktop platforms, XDG_DATA_HOME / ~/.local/share on Linux). + /// + public FileClientIdStore(string directory) + { + Directory = directory ?? throw new ArgumentNullException(nameof(directory)); + } + + /// + public async Task LoadAsync(HostId host, CancellationToken cancellationToken = default) + { + if (host is null) throw new ArgumentNullException(nameof(host)); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var path = FilePath(host); + string text; + try + { + // Read the bytes ourselves + decode UTF-8 to mirror Swift's + // Data(contentsOf:) + String(data:encoding:.utf8). A missing + // file (never stored) yields null, not an error. + var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + text = Encoding.UTF8.GetString(bytes); + } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + + var trimmed = text.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } + finally + { + _gate.Release(); + } + } + + /// + public async Task StoreAsync(HostId host, string clientId, CancellationToken cancellationToken = default) + { + if (host is null) throw new ArgumentNullException(nameof(host)); + if (clientId is null) throw new ArgumentNullException(nameof(clientId)); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + EnsureDirectory(); + var path = FilePath(host); + var bytes = Encoding.UTF8.GetBytes(clientId); + + // Atomic write: write to a unique temp file in the same directory, + // then File.Move(overwrite) — atomic on the same volume — so a + // concurrent reader never observes a half-written file (mirrors + // Swift's `.atomic` Data write option). + var tempPath = Path.Combine(Directory, "." + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + await File.WriteAllBytesAsync(tempPath, bytes, cancellationToken).ConfigureAwait(false); + // Set owner-only perms on the temp file BEFORE the move so the + // destination is never momentarily world-readable. + TrySetOwnerOnlyFile(tempPath); + File.Move(tempPath, path, overwrite: true); + } + catch + { + // Best-effort cleanup of the temp file on any failure so we + // don't leak partial writes into the directory. + TryDelete(tempPath); + throw; + } + } + finally + { + _gate.Release(); + } + } + + private void EnsureDirectory() + { + if (System.IO.Directory.Exists(Directory)) return; + System.IO.Directory.CreateDirectory(Directory); + // Best-effort restrict the directory to owner-only on Unix (0o700). + TrySetOwnerOnlyDirectory(Directory); + } + + private string FilePath(HostId host) => Path.Combine(Directory, Encode(host) + ".clientid"); + + /// + /// Percent-encodes a host id into a safe, stable filename component. Reuses + /// the same RFC-3986 unreserved-passthrough encoding as + /// (ALPHA / DIGIT / -._~ pass + /// through, everything else becomes %XX), mirroring Swift's + /// addingPercentEncoding(withAllowedCharacters:) over + /// alphanumerics + "-._~". The reverse direction isn't needed because + /// we only read files we wrote, by the same key. + /// + private static string Encode(HostId host) => HostedResourceKey.PercentEscape(host.ToString()); + + // ── Best-effort owner-only permissions (no-op off Unix) ─────────────────── + + private static void TrySetOwnerOnlyFile(string path) + { + if (!OperatingSystem.IsWindows()) + { + try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } + catch { /* best-effort: ignore on platforms/filesystems that reject it */ } + } + } + + private static void TrySetOwnerOnlyDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch { /* best-effort */ } + } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch { /* best-effort cleanup */ } + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs new file mode 100644 index 00000000..7ddfaa99 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs @@ -0,0 +1,82 @@ +// HostError — typed exceptions specific to the multi-host SDK layer. +// +// Faithful port of clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostError.swift. +// Swift models these as one `HostError` enum (unknownHost / hostReconnected / +// hostShutDown / duplicateHost / client). .NET prefers a small set of typed +// exception classes — one per case — each carrying the offending HostId so +// callers can `catch (DuplicateHostException ex)` and read `ex.HostId`. +// +// Errors from the underlying single-host AhpClient are NOT re-wrapped here: +// they propagate as the existing AhpException hierarchy (Errors.cs), mirroring +// Swift's `HostError.client(AHPClientError)` pass-through. +#nullable enable + +using System; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// Base exception for errors specific to the multi-host SDK layer +/// (). Carries the the error +/// pertains to. Port of Swift's HostError enum; each Swift case maps to +/// a concrete subclass here. +/// +public abstract class HostException : Exception +{ + /// The host this error pertains to. + public HostId HostId { get; } + + /// Creates a host exception for . + protected HostException(HostId hostId, string message) : base(message) + { + HostId = hostId; + } +} + +/// +/// Thrown when is called with a host +/// id that is already registered (or mid-add from a concurrent caller). Port of +/// Swift's HostError.duplicateHost(HostId). +/// +public sealed class DuplicateHostException : HostException +{ + /// Creates a duplicate-host exception. + public DuplicateHostException(HostId hostId) + : base(hostId, $"hosts: host {hostId} is already registered; remove it first") { } +} + +/// +/// Thrown when an operation references a host id that is not currently +/// registered. Port of Swift's HostError.unknownHost(HostId). +/// +public sealed class UnknownHostException : HostException +{ + /// Creates an unknown-host exception. + public UnknownHostException(HostId hostId) + : base(hostId, $"hosts: no host registered with id {hostId}") { } +} + +/// +/// Thrown when an operation requires a live connection but the host has no +/// connected client (e.g. dispatching while disconnected/failed). The +/// distinction from is that the host is +/// still registered — it just isn't connected right now. +/// +public sealed class HostNotConnectedException : HostException +{ + /// Creates a host-not-connected exception. + public HostNotConnectedException(HostId hostId) + : base(hostId, $"hosts: host {hostId} is not connected") { } +} + +/// +/// Thrown when a host's runtime has been torn down (the host was removed, or the +/// multi-host client was shut down). Outstanding handles for the host surface +/// this. Port of Swift's HostError.hostShutDown(HostId). +/// +public sealed class HostShutDownException : HostException +{ + /// Creates a host-shut-down exception. + public HostShutDownException(HostId hostId) + : base(hostId, $"hosts: host {hostId} runtime is no longer active") { } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs new file mode 100644 index 00000000..90da9d89 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs @@ -0,0 +1,81 @@ +// Stable (host, resource-URI) identity key. Mirrors Go's HostedResourceKey +// struct shape, plus a canonical percent-escaped string form so a host id and a +// resource URI compose into ONE collision-free key (a URI containing reserved +// characters like ':' '/' '?' can't be confused with the host/URI delimiter). +#nullable enable + +using System; +using System.Text; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// Identifies a resource on a particular host. Value type with value equality, +/// mirroring Go's HostedResourceKey. yields a +/// canonical string in which the URI component is percent-escaped per RFC 3986 +/// (unreserved characters pass through; everything else is %-escaped), so the +/// composed key is unambiguous. +/// +public readonly struct HostedResourceKey : IEquatable +{ + /// The host this resource belongs to. + public HostId HostId { get; } + + /// The resource URI (unescaped, as the protocol uses it). + public string Uri { get; } + + /// Creates a key from a host and a resource URI. + public HostedResourceKey(HostId hostId, string uri) + { + HostId = hostId ?? throw new ArgumentNullException(nameof(hostId)); + Uri = uri ?? throw new ArgumentNullException(nameof(uri)); + } + + /// + /// RFC 3986 unreserved set: ALPHA / DIGIT / '-' / '.' / '_' / '~'. These pass + /// through unescaped; every other byte is %-escaped. + /// + private static bool IsUnreserved(char c) => + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'; + + /// + /// Percent-escapes per RFC 3986 (UTF-8 bytes; uppercase + /// hex digits, matching the RFC's normalized form). + /// + public static string PercentEscape(string value) + { + if (value is null) throw new ArgumentNullException(nameof(value)); + var sb = new StringBuilder(value.Length); + foreach (byte b in Encoding.UTF8.GetBytes(value)) + { + char c = (char)b; + if (IsUnreserved(c)) sb.Append(c); + else sb.Append('%').Append(b.ToString("X2")); + } + return sb.ToString(); + } + + /// + /// The canonical key: the host id, a delimiter, and the percent-escaped URI. + /// Because the URI is escaped, the delimiter can never appear inside it. + /// + public string ToStableKey() => $"{HostId} {PercentEscape(Uri)}"; + + /// + public bool Equals(HostedResourceKey other) => + // Null-safe on HostId so a default(HostedResourceKey) compares cleanly + // (HostId is a reference type and is null on the default struct value). + Equals(HostId, other.HostId) && string.Equals(Uri, other.Uri, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is HostedResourceKey k && Equals(k); + + /// + public override int GetHashCode() => HashCode.Combine(HostId, Uri); + + /// + public override string ToString() => ToStableKey(); +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs new file mode 100644 index 00000000..8c132b0c --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs @@ -0,0 +1,1929 @@ +// Multi-host registry + reconnect supervisor. +// Faithful port of clients/go/ahp/hosts/hosts.go + multi_host_state_mirror.go. +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +// ─── HostId ────────────────────────────────────────────────────────────────── + +/// Opaque, stable identifier for a host registered with . +public sealed class HostId : IEquatable +{ + private readonly string _value; + + /// Creates a host ID from a string. The empty string is invalid. + public HostId(string value) + { + if (string.IsNullOrEmpty(value)) throw new ArgumentException("HostId must not be empty.", nameof(value)); + _value = value; + } + + /// + public override string ToString() => _value; + + /// + public bool Equals(HostId? other) => other is not null && _value == other._value; + + /// + public override bool Equals(object? obj) => obj is HostId h && Equals(h); + + /// + public override int GetHashCode() => _value.GetHashCode(StringComparison.Ordinal); + + /// Implicit conversion from string. + public static implicit operator HostId(string s) => new(s); +} + +// ─── HostState ─────────────────────────────────────────────────────────────── + +/// Lifecycle states a host can be in. +public enum HostStateKind +{ + /// Added but no transport is open. + Disconnected, + /// Transport is being opened or initialize is in flight. + Connecting, + /// Fully connected and serving subscriptions. + Connected, + /// Previous connection dropped; supervisor is retrying. + Reconnecting, + /// Reconnect attempts exhausted (or disabled). + Failed, +} + +/// Current lifecycle state of a host. +public sealed class HostState +{ + /// The state kind. + public HostStateKind Kind { get; init; } + + /// Consecutive reconnect attempt counter. + public uint Attempt { get; init; } + + /// The error that put the host into its current state, if any. + public Exception? Error { get; init; } + + /// + public override string ToString() => Kind switch + { + HostStateKind.Disconnected => "disconnected", + HostStateKind.Connecting => "connecting", + HostStateKind.Connected => "connected", + HostStateKind.Reconnecting => "reconnecting", + HostStateKind.Failed => "failed", + _ => "unknown", + }; +} + +// ─── ReconnectPolicy ───────────────────────────────────────────────────────── + +/// Controls reconnect behaviour after an unexpected transport drop. +public sealed class ReconnectPolicy +{ + /// + /// Caps consecutive retry attempts. Zero means unlimited. + /// + public uint MaxAttempts { get; init; } + + /// Wait before the first retry. + public TimeSpan InitialBackoff { get; init; } + + /// Caps the exponential backoff. + public TimeSpan MaxBackoff { get; init; } + + /// Scales each successive backoff. Use 2.0 for exponential. + public double BackoffMultiplier { get; init; } = 2.0; + + /// If true, resets the attempt counter after a successful reconnect. + public bool ResetOnSuccess { get; init; } + + /// + /// Randomizes each backoff by ±this fraction (clamped to 0–1) to avoid + /// reconnect storms when many hosts drop at once ("thundering herd"). The + /// default 0 disables jitter — matching the other AHP clients' behavior. + /// 0.2 is a reasonable production value. This is the dependency-free + /// equivalent of the "exponential backoff with jitter" that the .NET + /// resilience libraries recommend; see docs/decisions/reconnect.md. + /// + public double Jitter { get; init; } + + /// Whether reconnection is effectively disabled (zero initial backoff). + public bool IsDisabled => InitialBackoff <= TimeSpan.Zero; + + /// + /// Returns a policy with 1 s → 2 s → 4 s → … capped at 30 s, unlimited, reset on success. + /// + public static ReconnectPolicy Default { get; } = new() + { + InitialBackoff = TimeSpan.FromSeconds(1), + MaxBackoff = TimeSpan.FromSeconds(30), + BackoffMultiplier = 2.0, + ResetOnSuccess = true, + }; + + /// Returns a policy that disables reconnection. + public static ReconnectPolicy Disabled { get; } = new() + { + MaxAttempts = 0, + InitialBackoff = TimeSpan.Zero, + }; + + /// Computes the wait before attempt number (1-based). + internal TimeSpan BackoffFor(uint attempt) + { + if (IsDisabled) return TimeSpan.Zero; + var b = (double)InitialBackoff.Ticks; + var mult = BackoffMultiplier <= 0 ? 1.0 : BackoffMultiplier; + for (uint i = 1; i < attempt; i++) b *= mult; + var result = TimeSpan.FromTicks((long)b); + if (MaxBackoff > TimeSpan.Zero && result > MaxBackoff) result = MaxBackoff; + + if (Jitter > 0) + { + // Symmetric jitter: result * (1 ± Jitter), never negative and never + // above MaxBackoff. Random.Shared is thread-safe. + var j = Math.Clamp(Jitter, 0.0, 1.0); + var factor = 1.0 + (Random.Shared.NextDouble() * 2.0 - 1.0) * j; + var ticks = Math.Max(0L, (long)(result.Ticks * factor)); + result = TimeSpan.FromTicks(ticks); + if (MaxBackoff > TimeSpan.Zero && result > MaxBackoff) result = MaxBackoff; + } + + return result; + } +} + +// ─── HostConfig ────────────────────────────────────────────────────────────── + +/// Factory delegate that opens a fresh transport for a given host. +public delegate Task HostTransportFactory(HostId hostId, CancellationToken cancellationToken); + +/// Everything needs to supervise a single host. +public sealed class HostConfig +{ + /// Stable host identifier. Required. + public HostId Id { get; init; } = new("host"); + + /// Human-readable name surfaced on . + public string Label { get; init; } = ""; + + /// Stable AHP client ID. Leave empty to auto-generate and persist. + public string? ClientId { get; init; } + + /// URIs to subscribe to on initialize. Defaults to ["ahp-root://"]. + public IReadOnlyList? InitialSubscriptions { get; init; } + + /// Tunes the underlying driver. + public ClientConfig? ClientConfig { get; init; } + + /// Opens a transport for this host. Required. + public HostTransportFactory? TransportFactory { get; init; } + + /// Controls reconnect behaviour on drops. Defaults to . + public ReconnectPolicy? ReconnectPolicy { get; init; } + + /// Protocol versions advertised on initialize. Defaults to . + public IReadOnlyList? ProtocolVersions { get; init; } +} + +// ─── HostHandle ────────────────────────────────────────────────────────────── + +/// +/// Immutable snapshot of a registered host's observable state. Obtain a fresh +/// copy via to see updates. +/// +public sealed class HostHandle +{ + /// The host's stable identifier. + public HostId Id { get; init; } = new("host"); + + /// Human-readable label. + public string Label { get; init; } = ""; + + /// The stable AHP client ID sent on initialize. + public string ClientId { get; init; } = ""; + + /// Current lifecycle state. + public HostState State { get; init; } = new() { Kind = HostStateKind.Disconnected }; + + /// Protocol version negotiated on the last successful initialize. + public string ProtocolVersion { get; init; } = ""; + + /// Snapshot time. + public DateTimeOffset UpdatedAt { get; init; } + + // ── Swift-parity observable fields (mirrors HostHandle.swift) ────────── + // These mirror the Swift `HostHandle`'s richer surface so aggregated views + // and per-host streams have a per-host data source. They are populated by + // the supervisor from `initialize`'s root snapshot, an opportunistic + // `listSessions` seed, and session-summary notifications. + + /// + /// Agents currently advertised by the host (mirrored from the root-state + /// snapshot returned on initialize). Empty until the host first + /// connects. + /// + public IReadOnlyList Agents { get; init; } = Array.Empty(); + + /// + /// Cached session summaries, sorted by ModifiedAt descending. Seeded + /// by listSessions after each connect and kept fresh by + /// root/sessionAdded / root/sessionRemoved / + /// root/sessionSummaryChanged notifications. + /// + public IReadOnlyList SessionSummaries { get; init; } = Array.Empty(); + + /// Active session count from root state, when present. + public long? ActiveSessions { get; init; } + + /// URIs the supervisor will (re-)subscribe to across reconnects. + public IReadOnlyList Subscriptions { get; init; } = Array.Empty(); + + /// Highest serverSeq observed on this host. + public long ServerSeq { get; init; } + + /// + /// Wall-clock time of the most recent successful initialize / + /// reconnect. Null until the host first connects. + /// + public DateTimeOffset? LastConnectedAt { get; init; } + + /// + /// Generation counter — bumped on every connect or reconnect. Lets callers + /// detect that the host reconnected since a snapshot was taken. + /// + public ulong Generation { get; init; } +} + +// ─── Aggregated view types ───────────────────────────────────────────────────── + +/// +/// Aggregated session summary tagged with its host of origin. Returned by +/// . URIs are per-host scoped, +/// so two hosts can legitimately advertise the same Summary.Resource; +/// consumers should treat (HostId, Summary.Resource) as the compound key. +/// Port of Swift's HostedSessionSummary. +/// +public sealed class HostedSessionSummary +{ + /// Host that owns this summary. + public HostId HostId { get; } + + /// Human-readable label of the owning host. + public string HostLabel { get; } + + /// The underlying session summary. + public SessionSummary Summary { get; } + + /// Creates a host-tagged session summary. + public HostedSessionSummary(HostId hostId, string hostLabel, SessionSummary summary) + { + HostId = hostId; HostLabel = hostLabel; Summary = summary; + } +} + +/// +/// Aggregated agent descriptor tagged with its host of origin. Returned by +/// . Port of Swift's +/// HostedAgent. +/// +public sealed class HostedAgent +{ + /// Host that owns this agent. + public HostId HostId { get; } + + /// Human-readable label of the owning host. + public string HostLabel { get; } + + /// The underlying agent descriptor. + public AgentInfo Agent { get; } + + /// Creates a host-tagged agent descriptor. + public HostedAgent(HostId hostId, string hostLabel, AgentInfo agent) + { + HostId = hostId; HostLabel = hostLabel; Agent = agent; + } +} + +// ─── HostClientHandle ────────────────────────────────────────────────────────── + +/// +/// Generation-checked handle onto the underlying single-host +/// for a host. Issued by . Operations verify +/// the host is still registered and on the same Generation the handle was +/// minted at; if the host was removed/shut down they throw +/// , and if a reconnect replaced the connection +/// they throw (acquire a fresh handle). +/// Port of Swift's HostClientHandle (Swift surfaces the reconnect case as +/// hostReconnected; the .NET typed-error set folds that into "not the +/// connection you held — reacquire"). +/// +public sealed class HostClientHandle +{ + private readonly MultiHostClient _owner; + + /// The host this handle was issued for. + public HostId HostId { get; } + + /// The generation this handle was minted at. + public ulong Generation { get; } + + internal HostClientHandle(MultiHostClient owner, HostId hostId, ulong generation) + { + _owner = owner; HostId = hostId; Generation = generation; + } + + /// + /// Validates this handle and returns the underlying live client. Throws + /// if the host is no longer registered + /// (removed or the multi-host client shut down), or + /// if the host has reconnected (the + /// generation moved) or currently has no live connection. + /// + private AhpClient CheckAlive() + { + var entry = _owner.TryGetEntry(HostId); + if (entry is null) throw new HostShutDownException(HostId); + var snap = entry.Snapshot(); + if (snap.Generation != Generation) throw new HostNotConnectedException(HostId); + var client = entry.CurrentClient; + if (client is null) throw new HostNotConnectedException(HostId); + return client; + } + + /// + /// Throws if this handle is no longer valid (host removed → + /// ; reconnected/disconnected → + /// ). Mirrors Swift's checkAlive(). + /// + public void CheckAliveOrThrow() => CheckAlive(); + + /// + /// Dispatches an action through this connection on , + /// refusing (throwing) if the host was removed or the connection has been + /// replaced. Mirrors Swift's HostClientHandle.dispatch. + /// + /// The action to dispatch. + /// Channel URI the action targets. + /// + /// Optional caller-owned sequence number. When supplied, that exact value is + /// sent on the wire and recorded on the returned handle; when null, the + /// connection's next auto-incrementing sequence is used. Mirrors Swift's + /// HostClientHandle.dispatch(action:channel:clientSeq:). + /// + /// Cancels the send. + public async Task DispatchAsync( + StateAction action, + string channel, + long? clientSeq = null, + CancellationToken cancellationToken = default) + { + var client = CheckAlive(); + return await client.DispatchAsync(channel, action, clientSeq, cancellationToken).ConfigureAwait(false); + } +} + +// ─── IClientIdStore ────────────────────────────────────────────────────────── + +/// Persists the stable clientId used in AHP's reconnect flow. +public interface IClientIdStore +{ + /// Returns the stored client ID for , or null if absent. + Task LoadAsync(HostId host, CancellationToken cancellationToken = default); + + /// Persists for . + Task StoreAsync(HostId host, string clientId, CancellationToken cancellationToken = default); +} + +/// Thread-safe in-memory . Suitable for tests and short-lived processes. +public sealed class InMemoryClientIdStore : IClientIdStore +{ + private readonly ConcurrentDictionary _data = new(StringComparer.Ordinal); + + /// + public Task LoadAsync(HostId host, CancellationToken cancellationToken = default) => + Task.FromResult(_data.TryGetValue(host.ToString(), out var v) ? v : null); + + /// + public Task StoreAsync(HostId host, string clientId, CancellationToken cancellationToken = default) + { + _data[host.ToString()] = clientId; + return Task.CompletedTask; + } +} + +// ─── Events ────────────────────────────────────────────────────────────────── + +/// +/// A connection-level event for a registered host. Two shapes exist, mirroring +/// the relevant cases of Swift's HostEvent enum: +/// +/// a state change ( is false) carries +/// the host's new — Swift's stateChanged; and +/// a removal ( is true), emitted when +/// the host is removed via — +/// Swift's removed(HostId). A removal carries a sentinel +/// of kind (the host +/// is gone), so consumers should branch on first. +/// +/// +public sealed class HostEvent +{ + /// Which host this event belongs to. + public HostId HostId { get; } + + /// The new state. For a removal event this is a sentinel + /// (); branch on . + public HostState State { get; } + + /// + /// True when this event signals the host was removed from the registry + /// (mirrors Swift's HostEvent.removed(id)). False for ordinary state + /// transitions (mirrors Swift's HostEvent.stateChanged). + /// + public bool IsRemoved { get; } + + /// Creates a host state-change event ( = false). + public HostEvent(HostId hostId, HostState state) { HostId = hostId; State = state; IsRemoved = false; } + + private HostEvent(HostId hostId, HostState state, bool isRemoved) + { + HostId = hostId; State = state; IsRemoved = isRemoved; + } + + /// + /// Creates a host removed event for , mirroring + /// Swift's HostEvent.removed(id). Carries a sentinel + /// state since the host is gone. + /// + public static HostEvent Removed(HostId hostId) => + new(hostId, new HostState { Kind = HostStateKind.Disconnected }, isRemoved: true); +} + +/// An subscription event tagged with host + URI. +public sealed class HostSubscriptionEvent +{ + /// Which host emitted this event. + public HostId HostId { get; } + + /// The channel URI the event belongs to. + public string Channel { get; } + + /// The underlying subscription event. + public SubscriptionEvent Event { get; } + + /// Creates a host subscription event. + public HostSubscriptionEvent(HostId hostId, string channel, SubscriptionEvent @event) + { + HostId = hostId; Channel = channel; Event = @event; + } +} + +// ─── Internal per-host bookkeeping ─────────────────────────────────────────── + +internal sealed class HostEntry +{ + public HostId Id { get; } + public HostConfig Config { get; } + public string ClientId { get; set; } + + private readonly Gate _gate = new(); + // Published reference, read lock-free via CurrentClient. A reference read is + // atomic; `volatile` supplies the visibility a lock would otherwise provide. + private volatile AhpClient? _client; + private HostState _state = new() { Kind = HostStateKind.Disconnected }; + private string _protoVer = ""; + private DateTimeOffset _updatedAt = DateTimeOffset.UtcNow; + + // ── Swift-parity observable per-host state (guarded by _gate) ────────── + // Session summaries are keyed by their `Resource` URI so add/remove/change + // notifications mutate them by id, mirroring Swift's `sessionSummaries` dict + // in HostRuntime.swift. Snapshot() materializes them sorted by ModifiedAt + // descending. The rest mirror HostHandle.swift's richer fields. + private readonly Dictionary _sessionSummaries = new(StringComparer.Ordinal); + private List _agents = new(); + private long? _activeSessions; + private readonly List _subscriptions; + private long _serverSeq; + private DateTimeOffset? _lastConnectedAt; + private ulong _generation; + + public CancellationTokenSource LifetimeCts { get; } = new(); + public Task SupervisorTask { get; set; } = Task.CompletedTask; + + /// Task for the fire-and-forget pump loop started in OpenHostAsync. + public Task PumpTask { get; set; } = Task.CompletedTask; + + // ── Manual-reconnect signaling (Swift `manualReconnect` parity) ──────── + // `_manualReconnect` is a wake counter: ReconnectAsync releases it; the + // supervisor waits on it to short-circuit a backoff sleep or to wake from + // the `.failed` park (where the policy is exhausted/disabled). `_attemptCts` + // is the cancellation source for the CURRENT connect attempt — ReconnectAsync + // (and removal) cancels it so a slow `connectOnce`/transport-factory is + // aborted promptly rather than blocking the next attempt. + private readonly SemaphoreSlim _manualReconnect = new(0); + private volatile CancellationTokenSource? _attemptCts; + + /// + /// Request a manual reconnect: wake any backoff sleep / failed-park, and + /// abort a slow in-flight connect attempt so the next attempt starts fresh. + /// Mirrors Swift `HostRuntime.reconnect()`. + /// + public void SignalManualReconnect() + { + // Abort the in-flight attempt (slow factory / hung handshake) first… + try { _attemptCts?.Cancel(); } catch (ObjectDisposedException) { } + // …then wake the supervisor's wait so it loops back to a fresh attempt. + try { _manualReconnect.Release(); } catch (SemaphoreFullException) { } catch (ObjectDisposedException) { } + } + + /// + /// Awaits a manual-reconnect request or cancellation. + /// Returns true if a manual reconnect was requested, false if cancelled. + /// + public async Task WaitForManualReconnectAsync(CancellationToken ct) + { + try { await _manualReconnect.WaitAsync(ct).ConfigureAwait(false); return true; } + catch (OperationCanceledException) { return false; } + } + + /// + /// Awaits EITHER the current client's completion (a transport drop) OR a + /// manual-reconnect request, whichever happens first. Returns true if a + /// manual reconnect won the race, false if the connection dropped (or ct + /// cancelled). + /// + public async Task WaitForDropOrManualReconnectAsync(Task completion, CancellationToken ct) + { + // The manual wait must be cancellable independently of ct: if the DROP + // wins the race, a still-pending _manualReconnect.WaitAsync waiter would + // linger in the semaphore's FIFO queue and swallow the next + // ReconnectAsync Release() that is meant to wake the PARKED supervisor — + // leaving the host asleep forever (the manual reconnect never takes). + using var manualCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var manual = _manualReconnect.WaitAsync(manualCts.Token); + var winner = await Task.WhenAny(completion, manual).ConfigureAwait(false); + if (winner == manual) + { + // Observe the result so a faulted/cancelled wait doesn't go unhandled. + try { await manual.ConfigureAwait(false); } catch { } + return true; + } + // Drop won. Cancel the loser to release its semaphore slot. If it raced to + // completion and actually took a permit, hand the permit back so the + // pending manual reconnect is not lost. + manualCts.Cancel(); + try { await manual.ConfigureAwait(false); _manualReconnect.Release(); } + catch { /* cancelled before taking a permit — nothing to return */ } + return false; + } + + /// + /// Establishes a fresh per-attempt CancellationTokenSource linked to the + /// host lifetime token, returning its token. SignalManualReconnect() cancels + /// whatever attempt CTS is current, aborting a slow factory. + /// + public CancellationToken BeginAttempt() + { + var linked = CancellationTokenSource.CreateLinkedTokenSource(LifetimeCts.Token); + var prev = Interlocked.Exchange(ref _attemptCts, linked); + prev?.Dispose(); + return linked.Token; + } + + /// Disposes the current per-attempt CTS once an attempt concludes. + public void EndAttempt() + { + var prev = Interlocked.Exchange(ref _attemptCts, null); + prev?.Dispose(); + } + + /// Drain any pending manual-reconnect signals (after a connect lands). + public void DrainManualReconnectSignals() + { + while (_manualReconnect.CurrentCount > 0) + { + try { _manualReconnect.Wait(0); } catch { break; } + } + } + + public HostEntry(HostId id, HostConfig config, string clientId) + { + Id = id; Config = config; ClientId = clientId; + // Seed the replay subscription set from the normalized config so it + // survives reconnects (mirrors Swift HostRuntime seeding `subscriptions` + // from `config.initialSubscriptions`). + _subscriptions = config.InitialSubscriptions is { Count: > 0 } + ? new List(config.InitialSubscriptions) + : new List(); + } + + /// + /// The current client, or null if not connected. Lock-free: a reference read + /// is atomic and _client is volatile, so no lock is needed just + /// to read one published reference. + /// + public AhpClient? CurrentClient => _client; + + public void SetClient(AhpClient? client, string protoVer) + { + // _protoVer is read together with _state/_updatedAt by Snapshot(), so the + // write stays under the lock; the _client write is a volatile publish. + lock (_gate) { _client = client; _protoVer = protoVer; } + } + + public void SetState(HostState state) + { + lock (_gate) { _state = state; _updatedAt = DateTimeOffset.UtcNow; } + } + + /// An immutable, consistent snapshot of this host's public state. + public HostHandle Snapshot() + { + lock (_gate) + { + // Materialize summaries sorted by ModifiedAt descending (newest + // first), matching Swift's `sessionSummaries` sort contract. + var summaries = new List(_sessionSummaries.Values); + summaries.Sort(static (a, b) => + { + var byTime = b.ModifiedAt.CompareTo(a.ModifiedAt); + if (byTime != 0) return byTime; + // Stable tie-break on resource so equal timestamps are + // deterministic across calls. + return string.CompareOrdinal(a.Resource, b.Resource); + }); + + return new HostHandle + { + Id = Id, + Label = Config.Label, + ClientId = ClientId, + State = _state, + ProtocolVersion = _protoVer, + UpdatedAt = _updatedAt, + Agents = new List(_agents), + SessionSummaries = summaries, + ActiveSessions = _activeSessions, + Subscriptions = new List(_subscriptions), + ServerSeq = _serverSeq, + LastConnectedAt = _lastConnectedAt, + Generation = _generation, + }; + } + } + + // ── Swift-parity observable mutators (all take _gate) ────────────────── + + /// + /// Records a successful (re)connect: bumps the generation, stamps the + /// connect time, and applies the root snapshot (agents + activeSessions) + /// when present. Mirrors the `state.generation &+= 1` / root-snapshot block + /// in Swift's completeHandshake. + /// + public ulong ApplyConnected(RootState? root, long serverSeq) + { + lock (_gate) + { + _generation += 1; + _lastConnectedAt = DateTimeOffset.UtcNow; + _serverSeq = serverSeq; + if (root is not null) + { + _agents = root.Agents is { } a ? new List(a) : new List(); + _activeSessions = root.ActiveSessions; + } + return _generation; + } + } + + /// + /// Replaces the cached session summaries with the listSessions seed. + /// Mirrors the `state.sessionSummaries.removeAll()` + repopulate block in + /// Swift's completeHandshake. + /// + public void SeedSessionSummaries(IEnumerable items) + { + lock (_gate) + { + _sessionSummaries.Clear(); + foreach (var item in items) _sessionSummaries[item.Resource] = item; + } + } + + /// Adds or replaces a single cached summary (root/sessionAdded). + public void PutSessionSummary(SessionSummary summary) + { + lock (_gate) { _sessionSummaries[summary.Resource] = summary; } + } + + /// Removes a cached summary by URI (root/sessionRemoved). + public void RemoveSessionSummary(string uri) + { + lock (_gate) { _sessionSummaries.Remove(uri); } + } + + /// + /// Applies a partial summary patch in place (root/sessionSummaryChanged). + /// Identity fields (resource/provider/createdAt) are ignored per spec — + /// mirrors Swift's applySummaryChanges. + /// + public void ApplySummaryChange(string uri, PartialSessionSummary changes) + { + lock (_gate) + { + if (!_sessionSummaries.TryGetValue(uri, out var existing)) return; + if (changes.Title is { } title) existing.Title = title; + if (changes.Status is { } status) existing.Status = status; + if (changes.Activity is { } activity) existing.Activity = activity; + if (changes.ModifiedAt is { } modifiedAt) existing.ModifiedAt = modifiedAt; + if (changes.Project is { } project) existing.Project = project; + if (changes.Model is { } model) existing.Model = model; + if (changes.WorkingDirectory is { } wd) existing.WorkingDirectory = wd; + if (changes.Changes is { } summaryChanges) existing.Changes = summaryChanges; + _sessionSummaries[uri] = existing; + } + } + + /// Tracks a URI in the replay subscription set (idempotent). + public void AppendSubscription(string uri) + { + lock (_gate) { if (!_subscriptions.Contains(uri)) _subscriptions.Add(uri); } + } + + /// Drops a URI from the replay subscription set. + public void RemoveSubscription(string uri) + { + lock (_gate) { _subscriptions.Remove(uri); } + } +} + +// ─── MultiHostClient ───────────────────────────────────────────────────────── + +/// +/// Multi-host registry + reconnect supervisor. Manages N independent AHP hosts, +/// fans in their inbound events, and supervises reconnects per-host policy. +/// +public sealed class MultiHostClient : IAsyncDisposable +{ + private readonly ConcurrentDictionary _hosts = new(StringComparer.Ordinal); + private volatile IClientIdStore _store; + + private readonly List> _eventChannels = new(); + private readonly Gate _eventsLock = new(); + + private readonly List> _subChannels = new(); + private readonly Gate _subsLock = new(); + + // ── Per-host listener registries (Swift-parity, MultiHostClient-owned) ── + // These live on the facade (NOT on any single AhpClient), so they survive + // reconnects: replayed envelopes the supervisor fans out on reconnect reach + // them too. Mirrors `perResourceListeners` / hostSnapshots / sessionSummaries + // ownership in Swift's MultiHostClient.swift. + private readonly Gate _perHostLock = new(); + // Per-(hostId) bucket of per-(uri) event listeners for EventsForHost. + private readonly Dictionary> _perResourceListeners = new(StringComparer.Ordinal); + // Per-(hostId) bucket of HostHandle-snapshot listeners for HostSnapshots. + private readonly Dictionary>> _snapshotListeners = new(StringComparer.Ordinal); + // Per-(hostId) bucket of session-summary-list listeners for SessionSummaries. + private readonly Dictionary>>> _summaryListeners = new(StringComparer.Ordinal); + + private readonly CancellationTokenSource _rootCts = new(); + + // Set once ShutdownAsync has begun. Guards AddHostAsync (which throws + // HostShutDownException afterward, mirroring Swift's `add` post-shutdown + // behavior) and makes Shutdown idempotent. Read/written under _perHostLock. + private bool _didShutDown; + + // ── Construction ────────────────────────────────────────────────────── + + /// Creates a multi-host registry backed by an . + public MultiHostClient() : this(new InMemoryClientIdStore()) { } + + /// Creates a multi-host registry backed by the given store. + public MultiHostClient(IClientIdStore store) => _store = store; + + /// Swaps the . Call before any . + public MultiHostClient WithClientIdStore(IClientIdStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + return this; + } + + // ── Single-host convenience ─────────────────────────────────────────── + + /// + /// One-line constructor for the common "I just want one host" case. + /// Returns the client and the initial host handle. + /// + public static async Task<(MultiHostClient Client, HostHandle Handle)> SingleAsync( + HostConfig config, + CancellationToken cancellationToken = default) + { + var m = new MultiHostClient(); + try + { + var handle = await m.AddHostAsync(config, cancellationToken).ConfigureAwait(false); + return (m, handle); + } + catch + { + await m.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + // ── Host management ─────────────────────────────────────────────────── + + /// + /// Registers , opens its initial transport, runs the + /// initialize handshake, and starts the reconnect supervisor. Returns a + /// fresh snapshot. + /// + public async Task AddHostAsync( + HostConfig config, + CancellationToken cancellationToken = default) + { + if (config.Id is null) throw new ArgumentException("HostConfig.Id is required."); + if (config.TransportFactory is null) + throw new ArgumentException($"HostConfig.TransportFactory is required for {config.Id}."); + + // After shutdown, adding a host is rejected with HostShutDownException + // carrying the would-be host id (mirrors Swift `add` throwing + // `.hostShutDown(id)` once `didShutDown`). + lock (_perHostLock) + { + if (_didShutDown) throw new HostShutDownException(config.Id); + } + + var policy = config.ReconnectPolicy ?? ReconnectPolicy.Default; + var initialSubs = config.InitialSubscriptions is { Count: > 0 } + ? config.InitialSubscriptions + : new[] { ProtocolVersion.RootResourceUri }; + var protoVersions = config.ProtocolVersions is { Count: > 0 } + ? config.ProtocolVersions + : ProtocolVersion.Supported; + + // Resolve or mint a clientId. + var clientId = config.ClientId; + if (string.IsNullOrEmpty(clientId)) + { + clientId = await _store.LoadAsync(config.Id, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(clientId)) + clientId = GenerateClientId(); + } + await _store.StoreAsync(config.Id, clientId, cancellationToken).ConfigureAwait(false); + + var normalizedConfig = new HostConfig + { + Id = config.Id, + Label = config.Label, + ClientId = clientId, + InitialSubscriptions = initialSubs, + ClientConfig = config.ClientConfig, + TransportFactory = config.TransportFactory, + ReconnectPolicy = policy, + ProtocolVersions = protoVersions, + }; + + var entry = new HostEntry(config.Id, normalizedConfig, clientId); + + // Atomic add-if-absent: TryAdd is the check-then-act done correctly, + // with no separate lock and no race window. Duplicate ids surface the + // typed DuplicateHostException carrying the offending id (mirrors Swift + // `add` throwing `.duplicateHost(id)`). + if (!_hosts.TryAdd(config.Id.ToString(), entry)) + throw new DuplicateHostException(config.Id); + + // Initial connect; on failure remove the host and propagate. + try + { + await OpenHostAsync(entry, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + SetHostState(entry, new HostState { Kind = HostStateKind.Failed, Error = ex }); + _hosts.TryRemove(entry.Id.ToString(), out _); + throw; + } + + // Start supervisor. + entry.SupervisorTask = Task.Run(() => SuperviseAsync(entry)); + + return entry.Snapshot(); + } + + /// Returns a fresh snapshot of the host with , or null if not registered. + public HostHandle? Host(HostId id) => + _hosts.TryGetValue(id.ToString(), out var entry) ? entry.Snapshot() : null; + + /// Returns a fresh snapshot of every registered host. + public List Hosts() + { + // ConcurrentDictionary.Values is a moment-in-time snapshot — safe to + // enumerate without external locking. + var result = new List(); + foreach (var e in _hosts.Values) result.Add(e.Snapshot()); + return result; + } + + /// + /// Acquires a generation-checked client handle for , or + /// null if the host is not registered or has no live connection. The handle + /// refuses to operate once the host has been removed (throwing + /// ) or once a reconnect has replaced the + /// connection it was minted against. Mirrors Swift's client(for:). + /// + public HostClientHandle? ClientFor(HostId id) + { + if (!_hosts.TryGetValue(id.ToString(), out var entry)) return null; + var snap = entry.Snapshot(); + if (entry.CurrentClient is null) return null; + return new HostClientHandle(this, id, snap.Generation); + } + + // Internal accessor used by HostClientHandle to validate liveness against + // the live registry (returns null once the host is removed/shut down). + internal HostEntry? TryGetEntry(HostId id) => + _hosts.TryGetValue(id.ToString(), out var entry) ? entry : null; + + /// + /// Unregisters a host and tears down its supervisor and client. Throws + /// if no host with + /// is registered. Per-host streams (, + /// , ) for + /// this host are finished so their await foreach loops exit cleanly. + /// + public async Task RemoveHostAsync(HostId id, CancellationToken cancellationToken = default) + { + if (!_hosts.TryRemove(id.ToString(), out var entry)) + throw new UnknownHostException(id); + + // Finish per-host listener streams first so consumers observing them + // exit their loops as soon as the host is gone (mirrors Swift's + // `finishPerResourceListeners(for:)` on `remove(_:)`). + FinishPerHostListeners(id.ToString()); + + entry!.LifetimeCts.Cancel(); + var client = entry.CurrentClient; + if (client is not null) + { + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + } + try { await entry.SupervisorTask.ConfigureAwait(false); } catch { } + try { await entry.PumpTask.ConfigureAwait(false); } catch (OperationCanceledException) { } catch { } + entry.LifetimeCts.Dispose(); + + // Announce the removal on the connection-event stream, mirroring Swift's + // `broadcastHostEvent(.removed(id))` at the end of `remove(_:)`. Fired + // after teardown so a consumer that reacts to the removed event observes + // a host that is already gone (Host(id) == null). + BroadcastHostEvent(HostEvent.Removed(id)); + } + + // ── Event channels ──────────────────────────────────────────────────── + + /// + /// Returns a channel that receives state transitions. + /// Each call returns an independent channel; slow consumers drop events. + /// + public System.Threading.Channels.ChannelReader Events() + { + var ch = System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(64) + { FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest }); + lock (_eventsLock) { _eventChannels.Add(ch); } + return ch.Reader; + } + + /// + /// Returns a channel that receives every from + /// every registered host. + /// + public System.Threading.Channels.ChannelReader Subscriptions() + { + var ch = System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(256) + { FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest }); + lock (_subsLock) { _subChannels.Add(ch); } + return ch.Reader; + } + + // ── Shutdown ────────────────────────────────────────────────────────── + + /// Tears down every host and releases registered event channels. Idempotent. + public async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + lock (_perHostLock) + { + if (_didShutDown) return; + _didShutDown = true; + } + + _rootCts.Cancel(); + + var entries = new List(_hosts.Values); + _hosts.Clear(); + + // Finish per-host listener streams for every host so their consumers' + // `await foreach` loops exit (mirrors the perResourceListeners finish in + // Swift's shutdown()). + foreach (var entry in entries) FinishPerHostListeners(entry.Id.ToString()); + + foreach (var entry in entries) + { + entry.LifetimeCts.Cancel(); + // Wake a parked (failed/disabled) supervisor so it observes the + // cancellation and exits rather than blocking on its manual-reconnect + // wait forever. + entry.SignalManualReconnect(); + var client = entry.CurrentClient; + if (client is not null) + { + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + } + } + + // Wait for all supervisors and pump tasks. + foreach (var entry in entries) + { + try { await entry.SupervisorTask.ConfigureAwait(false); } catch { } + try { await entry.PumpTask.ConfigureAwait(false); } catch (OperationCanceledException) { } catch { } + entry.LifetimeCts.Dispose(); + } + + // Complete all event/subscription channel writers so consumers' await foreach terminates. + lock (_eventsLock) + { + foreach (var ch in _eventChannels) ch.Writer.TryComplete(); + } + lock (_subsLock) + { + foreach (var ch in _subChannels) ch.Writer.TryComplete(); + } + + _rootCts.Dispose(); + } + + /// + public async ValueTask DisposeAsync() + { + await ShutdownAsync().ConfigureAwait(false); + } + + // ── Internal: openHost, supervisor, pumpEvents ──────────────────────── + + private async Task OpenHostAsync(HostEntry entry, CancellationToken cancellationToken, bool isReconnect = false) + { + SetHostState(entry, new HostState { Kind = HostStateKind.Connecting }); + + var transport = await entry.Config.TransportFactory!(entry.Id, cancellationToken).ConfigureAwait(false); + var client = AhpClient.Connect( + transport, + entry.Config.ClientConfig, + null); + + // On a reconnect with a known serverSeq, issue the AHP `reconnect` command + // (clientId + lastSeenServerSeq) so the host REPLAYS the actions missed + // while disconnected, instead of re-initializing from scratch. Mirrors + // Swift's HostRuntime reconnect path. Falls back to a fresh `initialize` + // on the still-live client if the host can't replay (errors / non-replay). + if (isReconnect) + { + var snap = entry.Snapshot(); + ReconnectResult? reconnectResult = null; + try + { + reconnectResult = await client.ReconnectAsync( + snap.ClientId, snap.ServerSeq, entry.Config.InitialSubscriptions, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Host does not support `reconnect` (or it errored) — fall through + // to a fresh `initialize` on the still-live client below. A + // cancellation (shutdown/dispose) is NOT swallowed: it propagates + // so the supervisor tears down promptly instead of blocking on a + // fallback initialize. + } + if (reconnectResult?.Value is ReconnectReplayResult replay) + { + entry.SetClient(client, snap.ProtocolVersion); + _ = ApplyReconnectReplay(entry, replay); + await SeedSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); + SetHostState(entry, new HostState { Kind = HostStateKind.Connected }); + NotifyPerHostSnapshot(entry); + NotifyPerHostSummaries(entry); + entry.PumpTask = Task.Run(() => PumpEventsAsync(entry, client)); + return; + } + } + + InitializeResult result; + try + { + result = await client.InitializeAsync( + entry.ClientId, + entry.Config.ProtocolVersions, + entry.Config.InitialSubscriptions, + cancellationToken) + .ConfigureAwait(false); + } + catch + { + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + throw; + } + + entry.SetClient(client, result.ProtocolVersion); + + // Extract the root-state snapshot (agents + activeSessions) that the + // server returned for the root channel, mirroring the + // `init1.snapshots.first(where: resource == RootResourceURI)` block in + // Swift's completeHandshake. + var root = ExtractRootSnapshot(result); + var generation = entry.ApplyConnected(root, result.ServerSeq); + + // Opportunistic `listSessions` seed. Cheap on first connect; kept in + // sync by notifications afterward. Non-fatal: a host that doesn't + // answer (or is slow) leaves the cache untouched, exactly like Swift's + // `try? await client.request("listSessions", ...)`. We bound the wait + // with a short timeout so hosts that never answer don't stall the + // connect (the default request timeout is 30s). + await SeedSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); + + SetHostState(entry, new HostState { Kind = HostStateKind.Connected }); + + // Emit a post-connect snapshot + summary list to per-host stream + // listeners (the connect transition is the first "observable change" + // after listSessions lands), mirroring the `.connected` re-yields in + // Swift's hostSnapshots / sessionSummaries watchers. + NotifyPerHostSnapshot(entry); + NotifyPerHostSummaries(entry); + _ = generation; // bumped for parity; surfaced via HostHandle.Generation + + // Fan events out to subscribers. + entry.PumpTask = Task.Run(() => PumpEventsAsync(entry, client)); + } + + /// + /// Applies a reconnect-replay result: bumps the host generation (a reconnect + /// happened) + advances the serverSeq to the last replayed envelope, and fans + /// every replayed action out exactly like the live pump (host-state mirror + + /// global subscription fan-in + per-(host,uri) listeners) so consumers that + /// subscribed before the drop observe the actions missed while disconnected. + /// Missing URIs are left for the next subscribe cycle. + /// + private ulong ApplyReconnectReplay(HostEntry entry, ReconnectReplayResult replay) + { + var lastSeq = entry.Snapshot().ServerSeq; + if (replay.Actions is { } seqScan) + foreach (var env in seqScan) if (env.ServerSeq > lastSeq) lastSeq = env.ServerSeq; + var generation = entry.ApplyConnected(null, lastSeq); + + if (replay.Actions is { } actions) + { + foreach (var env in actions) + { + var evt = new SubscriptionEventAction(env); + ApplyEventToHostState(entry, evt); + var hostEv = new HostSubscriptionEvent(entry.Id, env.Channel, evt); + List> channels; + lock (_subsLock) { channels = new List>(_subChannels); } + foreach (var ch in channels) ch.Writer.TryWrite(hostEv); + BroadcastPerResourceEvent(entry.Id, env.Channel, evt); + } + } + return generation; + } + + /// + /// Pulls the out of the root-channel snapshot in an + /// , or null if no root snapshot is present. + /// + private static RootState? ExtractRootSnapshot(InitializeResult result) + { + if (result.Snapshots is null) return null; + foreach (var snap in result.Snapshots) + { + if (snap.Resource == ProtocolVersion.RootResourceUri && snap.State?.Root is { } root) + return root; + } + return null; + } + + /// + /// Issues a best-effort listSessions on the root channel and seeds the + /// host's summary cache. Bounded by a short timeout and fully non-fatal — + /// failures/timeouts leave the cache as-is. + /// + private static async Task SeedSessionSummariesAsync(HostEntry entry, AhpClient client, CancellationToken cancellationToken) + { + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(750)); + var listed = await client.RequestAsync( + "listSessions", + new ListSessionsParams { Channel = ProtocolVersion.RootResourceUri }, + timeoutCts.Token) + .ConfigureAwait(false); + if (listed?.Items is { } items) + entry.SeedSessionSummaries(items); + } + catch + { + // Non-fatal: host did not answer listSessions in time, or returned + // an error. Cache stays as-is (matches Swift `try?`). + } + } + + private async Task PumpEventsAsync(HostEntry entry, AhpClient client) + { + var stream = client.CreateEventStream(); + try + { + // Pass the lifetime token so a shutdown/removal (LifetimeCts.Cancel) + // reliably unblocks this read instead of relying solely on the event + // channel completing — a race that could hang teardown's `await + // PumpTask`. The OperationCanceledException is caught below as the + // normal-shutdown path. + await foreach (var ev in stream.Events.ReadAllAsync(entry.LifetimeCts.Token).ConfigureAwait(false)) + { + // Update per-host observable state BEFORE broadcasting so any + // observer reading the next snapshot sees the post-event state + // (mirrors the ordering in Swift HostRuntime.handleEvent). + var summaryTouched = ApplyEventToHostState(entry, ev.Event); + + var hostEv = new HostSubscriptionEvent(entry.Id, ev.Channel, ev.Event); + List> channels; + lock (_subsLock) { channels = new List>(_subChannels); } + foreach (var ch in channels) ch.Writer.TryWrite(hostEv); + + // Fan to per-(host,uri) listeners scoped to this channel + // (reducer-critical reliable path, runtime-owned so it survives + // reconnect — Swift's perResourceListeners). + BroadcastPerResourceEvent(entry.Id, ev.Channel, ev.Event); + + // A session-summary-shaped notification advanced the cache: + // re-yield the snapshot + summary list to per-host listeners. + if (summaryTouched) + { + NotifyPerHostSnapshot(entry); + NotifyPerHostSummaries(entry); + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown via LifetimeCts. + } + catch (Exception ex) + { + // Unexpected pump failure — mark the host as failed. + SetHostState(entry, new HostState + { + Kind = HostStateKind.Failed, + Error = ex, + }); + } + finally + { + stream.Close(); + } + } + + private async Task SuperviseAsync(HostEntry entry) + { + var policy = entry.Config.ReconnectPolicy ?? ReconnectPolicy.Default; + var ct = entry.LifetimeCts.Token; + + while (true) + { + if (ct.IsCancellationRequested) return; + var client = entry.CurrentClient; + if (client is null) return; + + // Wait for either a transport drop (client.Completion) OR a manual + // reconnect request. A manual reconnect on a connected host wins the + // race and forces a fresh connect cycle below (mirrors Swift's + // `manualReconnect` case interrupting `runConnection`). + bool manualWhileConnected; + try + { + manualWhileConnected = await entry.WaitForDropOrManualReconnectAsync(client.Completion, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + + if (ct.IsCancellationRequested) return; + + // Tear the old client down before reconnecting (whether it dropped + // or we're forcing a manual reconnect). + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + entry.SetClient(null, ""); + + // A manual reconnect bypasses the reconnect policy entirely — even a + // `.disabled` policy reconnects on explicit request. A spontaneous + // drop on a disabled policy parks in `.failed` (then waits for a + // manual reconnect to wake). + if (!manualWhileConnected && policy.IsDisabled) + { + SetHostState(entry, new HostState + { + Kind = HostStateKind.Failed, + Error = new Exception("hosts: transport closed and reconnect disabled"), + }); + if (!await ParkUntilManualReconnectAsync(entry, ct).ConfigureAwait(false)) return; + manualWhileConnected = true; // woken explicitly; bypass backoff + } + + // Reconnect attempt loop. A manual reconnect skips the backoff sleep + // for the first attempt (immediate). Per-attempt cancellation lets a + // later manual reconnect / removal abort a slow transport factory. + uint attempt = 1; + bool immediate = manualWhileConnected; + while (true) + { + if (ct.IsCancellationRequested) return; + SetHostState(entry, new HostState { Kind = HostStateKind.Reconnecting, Attempt = attempt }); + + if (!immediate) + { + var delay = policy.BackoffFor(attempt); + try { await Task.Delay(delay, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + immediate = false; + + var attemptCt = entry.BeginAttempt(); + try + { + await OpenHostAsync(entry, attemptCt, isReconnect: true).ConfigureAwait(false); + entry.EndAttempt(); + entry.DrainManualReconnectSignals(); + break; // reconnected successfully + } + catch (OperationCanceledException) + { + entry.EndAttempt(); + // Distinguish a lifetime cancel (shut down → exit) from an + // attempt-scoped cancel triggered by a manual reconnect / + // removal aborting a slow factory. + if (ct.IsCancellationRequested) return; + // Manual reconnect aborted this attempt: retry immediately. + entry.DrainManualReconnectSignals(); + immediate = true; + continue; + } + catch + { + entry.EndAttempt(); + /* retry after backoff */ + } + + attempt++; + if (policy.MaxAttempts > 0 && attempt > policy.MaxAttempts) + { + SetHostState(entry, new HostState + { + Kind = HostStateKind.Failed, + Error = new Exception($"hosts: exceeded {policy.MaxAttempts} reconnect attempts"), + }); + // Park in `.failed` until a manual reconnect wakes us (a + // manual reconnect bypasses the exhausted policy), mirroring + // Swift's `waitForManualReconnectOrShutdown`. + if (!await ParkUntilManualReconnectAsync(entry, ct).ConfigureAwait(false)) return; + attempt = 1; + immediate = true; + } + } + } + } + + /// + /// Parks a host in its terminal (.failed) state until a manual + /// reconnect is requested or the host lifetime is cancelled. Returns true if + /// a manual reconnect woke it (caller should re-attempt), false on + /// cancellation (caller should exit). Mirrors Swift's + /// waitForManualReconnectOrShutdown. + /// + private static async Task ParkUntilManualReconnectAsync(HostEntry entry, CancellationToken ct) + { + entry.DrainManualReconnectSignals(); + return await entry.WaitForManualReconnectAsync(ct).ConfigureAwait(false); + } + + private void SetHostState(HostEntry entry, HostState state) + { + entry.SetState(state); + + BroadcastHostEvent(new HostEvent(entry.Id, state)); + + // A state transition is an observable change for hostSnapshots + // consumers (Swift re-yields a fresh snapshot on `.stateChanged`). + NotifyPerHostSnapshot(entry); + } + + /// + /// Fans out to every registered + /// reader. Mirrors Swift's broadcastHostEvent. Slow consumers drop + /// oldest (the channels are DropOldest-bounded). + /// + private void BroadcastHostEvent(HostEvent ev) + { + List> channels; + lock (_eventsLock) { channels = new List>(_eventChannels); } + foreach (var ch in channels) ch.Writer.TryWrite(ev); + } + + private static string GenerateClientId() + { + var bytes = RandomNumberGenerator.GetBytes(16); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + // ── Per-host observable plumbing ────────────────────────────────────── + + /// + /// Applies a subscription event to a host's cached observable state. Returns + /// true if the event mutated the session-summary cache (so per-host snapshot + /// / summary listeners should be re-yielded). Mirrors the cache mutations in + /// Swift's HostRuntime.handleEvent + applyAction. + /// + private static bool ApplyEventToHostState(HostEntry entry, SubscriptionEvent ev) + { + switch (ev) + { + case SubscriptionEventSessionAdded added: + entry.PutSessionSummary(added.Params.Summary); + return true; + case SubscriptionEventSessionRemoved removed: + entry.RemoveSessionSummary(removed.Params.Session); + return true; + case SubscriptionEventSessionSummaryChanged changed: + entry.ApplySummaryChange(changed.Params.Session, changed.Params.Changes); + return true; + default: + return false; + } + } + + /// + /// Fans an event scoped to to every per-(host,uri) + /// listener whose URI matches. Listeners are runtime-owned so they survive + /// reconnect. Mirrors the per-channel fan-out in Swift's + /// broadcastSubscriptionEvent. + /// + private void BroadcastPerResourceEvent(HostId hostId, string channel, SubscriptionEvent ev) + { + List? listeners; + lock (_perHostLock) + { + if (!_perResourceListeners.TryGetValue(hostId.ToString(), out var bucket)) return; + listeners = new List(bucket); + } + foreach (var l in listeners) + { + if (l.Uri == channel) l.Channel.Writer.TryWrite(ev); + } + } + + /// Re-yields a fresh snapshot to per-host snapshot listeners. + private void NotifyPerHostSnapshot(HostEntry entry) + { + List>? listeners; + lock (_perHostLock) + { + if (!_snapshotListeners.TryGetValue(entry.Id.ToString(), out var bucket) || bucket.Count == 0) return; + listeners = new List>(bucket); + } + var snap = entry.Snapshot(); + foreach (var ch in listeners) ch.Writer.TryWrite(snap); + } + + /// Re-yields the current sorted summary list to per-host summary listeners. + private void NotifyPerHostSummaries(HostEntry entry) + { + List>>? listeners; + lock (_perHostLock) + { + if (!_summaryListeners.TryGetValue(entry.Id.ToString(), out var bucket) || bucket.Count == 0) return; + listeners = new List>>(bucket); + } + var summaries = entry.Snapshot().SessionSummaries; + foreach (var ch in listeners) ch.Writer.TryWrite(summaries); + } + + /// + /// Finishes (completes) every per-host listener stream for + /// and drops the buckets, so consumers' await foreach loops exit. Called + /// on host removal and shutdown. Mirrors Swift's finishPerResourceListeners. + /// + private void FinishPerHostListeners(string hostId) + { + List? perResource = null; + List>? snapshots = null; + List>>? summaries = null; + lock (_perHostLock) + { + if (_perResourceListeners.Remove(hostId, out var b1)) perResource = b1; + if (_snapshotListeners.Remove(hostId, out var b2)) snapshots = b2; + if (_summaryListeners.Remove(hostId, out var b3)) summaries = b3; + } + if (perResource is not null) foreach (var l in perResource) l.Channel.Writer.TryComplete(); + if (snapshots is not null) foreach (var ch in snapshots) ch.Writer.TryComplete(); + if (summaries is not null) foreach (var ch in summaries) ch.Writer.TryComplete(); + } + + // ── Per-host streams (Swift-parity public API) ──────────────────────── + + /// + /// Per-(host, uri) event stream — the reliable channel for + /// reducer-critical action envelopes. Delivers every event scoped to + /// on , both live and replayed + /// across reconnects (the listener is owned by this facade, not by any single + /// ). The stream finishes when the host is removed or + /// the client shuts down. Mirrors Swift's events(host:uri:). + /// + /// Throws if no host with + /// is registered. (Swift returns nil here; the .NET + /// surface throws a typed error, per the parity test contract.) + /// + public System.Threading.Channels.ChannelReader EventsForHost(HostId host, string uri) + { + lock (_perHostLock) + { + if (!_hosts.ContainsKey(host.ToString())) throw new UnknownHostException(host); + var ch = System.Threading.Channels.Channel.CreateUnbounded(); + var listener = new PerResourceListener(uri, ch); + if (!_perResourceListeners.TryGetValue(host.ToString(), out var bucket)) + { + bucket = new List(); + _perResourceListeners[host.ToString()] = bucket; + } + bucket.Add(listener); + return ch.Reader; + } + } + + /// + /// Observable stream of snapshots for + /// . Yields the current snapshot immediately, then a + /// fresh snapshot whenever the host's observable state changes (connection + /// state transitions, reconnect completion, session-summary updates). The + /// stream finishes when the host is removed. Mirrors Swift's + /// hostSnapshots(host:). + /// + /// Throws if no host with + /// is registered. + /// + public System.Threading.Channels.ChannelReader HostSnapshots(HostId host) + { + lock (_perHostLock) + { + if (!_hosts.TryGetValue(host.ToString(), out var entry)) throw new UnknownHostException(host); + // bufferingNewest(1)-equivalent: only the latest snapshot matters to + // a UI consumer, so slow consumers drop intermediate snapshots. + var ch = System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(1) + { FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest }); + if (!_snapshotListeners.TryGetValue(host.ToString(), out var bucket)) + { + bucket = new List>(); + _snapshotListeners[host.ToString()] = bucket; + } + bucket.Add(ch); + // Dispatch the initial snapshot as the first stream element. + ch.Writer.TryWrite(entry.Snapshot()); + return ch.Reader; + } + } + + /// + /// Observable stream of cached session summaries for , + /// sorted by ModifiedAt descending. Yields the current cache + /// immediately, then a fresh sorted list whenever the cache changes + /// (listSessions refresh on connect, or session add/remove/summary-change + /// notifications). The stream finishes when the host is removed. Mirrors + /// Swift's sessionSummaries(host:). + /// + /// Throws if no host with + /// is registered. + /// + public System.Threading.Channels.ChannelReader> SessionSummariesForHost(HostId host) + { + lock (_perHostLock) + { + if (!_hosts.TryGetValue(host.ToString(), out var entry)) throw new UnknownHostException(host); + var ch = System.Threading.Channels.Channel.CreateBounded>( + new System.Threading.Channels.BoundedChannelOptions(1) + { FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest }); + if (!_summaryListeners.TryGetValue(host.ToString(), out var bucket)) + { + bucket = new List>>(); + _summaryListeners[host.ToString()] = bucket; + } + bucket.Add(ch); + ch.Writer.TryWrite(entry.Snapshot().SessionSummaries); + return ch.Reader; + } + } + + // ── Aggregated views (Swift-parity public API) ──────────────────────── + + /// + /// Aggregated session summaries across every registered host, sorted by + /// Summary.ModifiedAt descending. Each row carries the originating + /// host id + label so consumers render a unified inbox without losing host + /// attribution. Tie-break for equal timestamps: host registration order, + /// then Summary.Resource. Mirrors Swift's aggregatedSessions(). + /// + public List AggregatedSessions() + { + // Registration order for the secondary tie-break, captured once. + var order = new List(_hosts.Values); + var orderIndex = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < order.Count; i++) orderIndex[order[i].Id.ToString()] = i; + + var rows = new List(); + foreach (var entry in order) + { + var snap = entry.Snapshot(); + foreach (var summary in snap.SessionSummaries) + rows.Add(new HostedSessionSummary(snap.Id, snap.Label, summary)); + } + + rows.Sort((a, b) => + { + if (a.Summary.ModifiedAt != b.Summary.ModifiedAt) + return b.Summary.ModifiedAt.CompareTo(a.Summary.ModifiedAt); // newest first + var ai = orderIndex.TryGetValue(a.HostId.ToString(), out var x) ? x : int.MaxValue; + var bi = orderIndex.TryGetValue(b.HostId.ToString(), out var y) ? y : int.MaxValue; + if (ai != bi) return ai.CompareTo(bi); + return string.CompareOrdinal(a.Summary.Resource, b.Summary.Resource); + }); + return rows; + } + + /// + /// Aggregated agents across every registered host, in registration order per + /// host. Each row carries the originating host id + label. Mirrors Swift's + /// aggregatedAgents(). + /// + public List AggregatedAgents() + { + var rows = new List(); + foreach (var entry in _hosts.Values) + { + var snap = entry.Snapshot(); + foreach (var agent in snap.Agents) + rows.Add(new HostedAgent(snap.Id, snap.Label, agent)); + } + return rows; + } + + // ── Manual reconnect (Swift-parity public API) ──────────────────────── + + /// + /// Triggers a manual reconnect on . Cancels any in-flight + /// backoff sleep (or slow transport factory) and forces a fresh connect + /// attempt — even when the host is in Failed with an exhausted/disabled + /// reconnect policy. Mirrors Swift's reconnect(_:). + /// + /// Throws if no host with + /// is registered. + /// + public Task ReconnectAsync(HostId id, CancellationToken cancellationToken = default) + { + if (!_hosts.TryGetValue(id.ToString(), out var entry)) + throw new UnknownHostException(id); + entry.SignalManualReconnect(); + return Task.CompletedTask; + } + + /// + /// Triggers a manual reconnect on every registered host that is NOT currently + /// Connected or Connecting (i.e. Disconnected, + /// Reconnecting, or Failed). Connected / actively-connecting + /// hosts are skipped. Returns a map of host id → error for hosts whose + /// reconnect request could not be dispatched; the call itself does not throw. + /// Mirrors Swift's reconnectAllUnavailable(). + /// + public Task> ReconnectAllUnavailableAsync(CancellationToken cancellationToken = default) + { + var errors = new Dictionary(); + foreach (var entry in _hosts.Values) + { + var snap = entry.Snapshot(); + switch (snap.State.Kind) + { + case HostStateKind.Connected: + case HostStateKind.Connecting: + continue; // skip — already connected or actively connecting + default: + try { entry.SignalManualReconnect(); } + catch (Exception ex) { errors[entry.Id] = ex; } + break; + } + } + return Task.FromResult(errors); + } + + // ── Per-host dispatch / subscribe (typed-error surface) ─────────────── + + /// + /// Dispatches on for + /// . Throws if no + /// such host is registered, or if the + /// host has no live connection. Mirrors Swift's dispatch(host:…). + /// + /// Target host. + /// The action to dispatch. + /// Channel URI the action targets. + /// + /// Optional caller-owned sequence number. When supplied, that exact value is + /// sent on the wire and recorded on the returned handle — for an app-level + /// outbox that needs stable sequence numbers across reconnect/replay; when + /// null, the connection's next auto-incrementing sequence is used. + /// Mirrors Swift's dispatch(host:action:channel:clientSeq:). + /// + /// Cancels the send. + public async Task DispatchAsync( + HostId host, + StateAction action, + string channel, + long? clientSeq = null, + CancellationToken cancellationToken = default) + { + if (!_hosts.TryGetValue(host.ToString(), out var entry)) + throw new UnknownHostException(host); + var client = entry.CurrentClient; + if (client is null) + throw new HostNotConnectedException(host); + return await client.DispatchAsync(channel, action, clientSeq, cancellationToken).ConfigureAwait(false); + } + + /// + /// Subscribes to on , tracking + /// the URI for replay across reconnects. Throws + /// if no such host is registered, or if + /// the host has no live connection. Mirrors Swift's subscribe(host:uri:). + /// + public async Task SubscribeAsync( + HostId host, + string uri, + CancellationToken cancellationToken = default) + { + if (!_hosts.TryGetValue(host.ToString(), out var entry)) + throw new UnknownHostException(host); + var client = entry.CurrentClient; + if (client is null) + throw new HostNotConnectedException(host); + var sub = client.AttachSubscription(uri); + try + { + // Issue the subscribe RPC; track the URI for replay on success. + var result = await client.RequestAsync( + "subscribe", + new SubscribeParams { Channel = uri }, + cancellationToken).ConfigureAwait(false); + entry.AppendSubscription(uri); + return result; + } + catch + { + sub.Dispose(); + throw; + } + } + + /// + /// Unsubscribes from on , sending + /// the unsubscribe notification, closing the host client's local + /// subscriptions for the URI, and dropping the URI from the replay set so it is + /// no longer re-subscribed across reconnects. Throws + /// if no such host is registered, or + /// if the host has no live connection. + /// Mirrors Swift's unsubscribe(host:uri:). + /// + /// Divergence note. Swift's runtime drops the URI from the replay + /// set even when the host is disconnected (unsubscribe-while-disconnected is a + /// no-op send that still mutates the replay set). The .NET surface instead + /// surfaces the no-live-connection case as a typed + /// — symmetric with + /// , which makes the same choice. The replay-set + /// drop therefore happens only on the connected path here. Callers that want to + /// forget a URI on a disconnected host can remove + re-add the host, or + /// unsubscribe once it reconnects. + /// + public async Task UnsubscribeAsync( + HostId host, + string uri, + CancellationToken cancellationToken = default) + { + if (!_hosts.TryGetValue(host.ToString(), out var entry)) + throw new UnknownHostException(host); + var client = entry.CurrentClient; + if (client is null) + throw new HostNotConnectedException(host); + + // Send the unsubscribe RPC + close the host client's local per-URI + // subscriptions, then forget the URI for replay. Order matches Swift's + // handleUnsubscribe (RPC first, then removeSubscription). + await client.UnsubscribeAsync(uri, cancellationToken).ConfigureAwait(false); + entry.RemoveSubscription(uri); + } + + /// + /// One per-(host, uri) listener registered via + /// . Held in the facade's + /// _perResourceListeners registry so it outlives any single + /// and survives reconnects. Mirrors Swift's + /// PerResourceListener. + /// + private sealed class PerResourceListener + { + public string Uri { get; } + public System.Threading.Channels.Channel Channel { get; } + + public PerResourceListener(string uri, System.Threading.Channels.Channel channel) + { + Uri = uri; Channel = channel; + } + } +} + +// ─── MultiHostStateMirror ───────────────────────────────────────────────────── + +/// +/// Thread-safe map of (hostId, URI) → state snapshot. Port of +/// multi_host_state_mirror.go. Writes snapshots in; reads them back; +/// drops them when the host or resource disappears. +/// +public sealed class MultiHostStateMirror +{ + // Independent per-key snapshots: ConcurrentDictionary gives lock-free + // reads and fine-grained writes, which is exactly this access pattern. + // The per-resource maps key by HostedResourceKey (host + URI value type) so a + // host id and a URI compose into one collision-free key with value equality — + // no ad-hoc tuple delimiter to confuse with reserved URI characters. + private readonly ConcurrentDictionary _roots = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _terminals = new(); + private readonly ConcurrentDictionary _changesets = new(); + + /// Stores for . + public void PutRoot(string hostId, RootState root) => _roots[hostId] = root; + + /// Returns the root snapshot for , or (default, false) if absent. + public (RootState? Value, bool Found) Root(string hostId) => + _roots.TryGetValue(hostId, out var v) ? (v, true) : (default, false); + + /// Stores a session snapshot under (hostId, uri). + public void PutSession(string hostId, string uri, SessionState state) => _sessions[new HostedResourceKey(hostId, uri)] = state; + + /// Returns the session snapshot at (hostId, uri), or (default, false) if absent. + public (SessionState? Value, bool Found) Session(string hostId, string uri) => + _sessions.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); + + /// Stores a terminal snapshot under (hostId, uri). + public void PutTerminal(string hostId, string uri, TerminalState state) => _terminals[new HostedResourceKey(hostId, uri)] = state; + + /// Returns the terminal snapshot at (hostId, uri), or (default, false) if absent. + public (TerminalState? Value, bool Found) Terminal(string hostId, string uri) => + _terminals.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); + + /// Stores a changeset snapshot under (hostId, uri). + public void PutChangeset(string hostId, string uri, ChangesetState state) => _changesets[new HostedResourceKey(hostId, uri)] = state; + + /// Returns the changeset snapshot at (hostId, uri), or (default, false) if absent. + public (ChangesetState? Value, bool Found) Changeset(string hostId, string uri) => + _changesets.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); + + /// Removes every snapshot belonging to . + public void DropHost(string hostId) + { + _roots.TryRemove(hostId, out _); + foreach (var k in _sessions.Keys) if (k.HostId.ToString() == hostId) _sessions.TryRemove(k, out _); + foreach (var k in _terminals.Keys) if (k.HostId.ToString() == hostId) _terminals.TryRemove(k, out _); + foreach (var k in _changesets.Keys) if (k.HostId.ToString() == hostId) _changesets.TryRemove(k, out _); + } + + /// Removes the snapshot at (hostId, uri) across every resource kind. + public void DropResource(string hostId, string uri) + { + var key = new HostedResourceKey(hostId, uri); + _sessions.TryRemove(key, out _); + _terminals.TryRemove(key, out _); + _changesets.TryRemove(key, out _); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs new file mode 100644 index 00000000..ff06b25a --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -0,0 +1,1541 @@ +// Pure state reducers — a faithful port of the Go client's reducers.go, +// which in turn mirrors the canonical TypeScript reducers. Each reducer +// mutates the supplied state in place and reports whether it applied. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Microsoft.AgentHostProtocol; + +/// What happened when a reducer was asked to apply an action. +public enum ReduceOutcome +{ + /// The action was applied and the state was mutated. + Applied, + + /// The action was recognized but had no effect against this state. + NoOp, + + /// The action targets a different scope (e.g. a session action passed to the root reducer). + OutOfScope, +} + +/// +/// Pure reducers for the Agent Host Protocol. , +/// , , and +/// apply a to the +/// matching state tree in place. +/// +public static class Reducers +{ + // ─── Injectable timestamp ────────────────────────────────────────────── + + private static readonly Gate s_nowLock = new(); + private static Func s_now = () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + /// + /// Overrides the function reducers call to stamp summary.modifiedAt. + /// Useful for tests that need deterministic output. Pass + /// to restore the default (current Unix time in milliseconds). + /// + public static void SetNowProvider(Func? provider) + { + lock (s_nowLock) + { + s_now = provider ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + } + + private static long NowMs() + { + lock (s_nowLock) + { + return s_now(); + } + } + + // Mirrors Go's `append([]T(nil), src...)`: a null source yields a null + // result (which serializes as absent / null and is stripped by the + // conformance harness), a non-null source yields a shallow copy. + private static List? CopyList(List? src) => src is null ? null : new List(src); + + // ─── Status helpers ──────────────────────────────────────────────────── + + // Covers the mutually-exclusive activity bits (bits 0–4) of SessionStatus. + private const SessionStatus StatusActivityMask = (SessionStatus)((1u << 5) - 1); + + private static SessionStatus WithStatusFlag(SessionStatus status, SessionStatus flag, bool set) => + set ? status | flag : status & ~flag; + + // ─── Tool-call helpers ───────────────────────────────────────────────── + + private readonly record struct ToolCallCommon( + string Id, + string Name, + string DisplayName, + ToolCallContributor? Contributor, + Dictionary? Meta); + + private static ToolCallCommon ToolCallMeta(ToolCallState tc) => tc.Value switch + { + ToolCallStreamingState v => new(v.ToolCallId, v.ToolName, v.DisplayName, v.Contributor, v.Meta), + ToolCallPendingConfirmationState v => new(v.ToolCallId, v.ToolName, v.DisplayName, v.Contributor, v.Meta), + ToolCallRunningState v => new(v.ToolCallId, v.ToolName, v.DisplayName, v.Contributor, v.Meta), + ToolCallPendingResultConfirmationState v => new(v.ToolCallId, v.ToolName, v.DisplayName, v.Contributor, v.Meta), + ToolCallCompletedState v => new(v.ToolCallId, v.ToolName, v.DisplayName, v.Contributor, v.Meta), + ToolCallCancelledState v => new(v.ToolCallId, v.ToolName, v.DisplayName, v.Contributor, v.Meta), + _ => default, + }; + + private static (StringOrMarkdown Invocation, string? ToolInput) ToolCallInvocationAndInput(ToolCallState tc) => + tc.Value switch + { + ToolCallStreamingState v => (v.InvocationMessage ?? new StringOrMarkdown(), null), + ToolCallPendingConfirmationState v => (v.InvocationMessage, v.ToolInput), + ToolCallRunningState v => (v.InvocationMessage, v.ToolInput), + ToolCallPendingResultConfirmationState v => (v.InvocationMessage, v.ToolInput), + _ => (new StringOrMarkdown(), null), + }; + + private static string ToolCallId(ToolCallState tc) => ToolCallMeta(tc).Id; + + private static bool HasPendingToolCallConfirmation(SessionState state) + { + if (state.ActiveTurn is null) + { + return false; + } + + foreach (ResponsePart part in state.ActiveTurn.ResponseParts) + { + if (part.Value is not ToolCallResponsePart tc) + { + continue; + } + + if (tc.ToolCall.Value is ToolCallPendingConfirmationState or ToolCallPendingResultConfirmationState) + { + return true; + } + } + + return false; + } + + private static SessionStatus SummaryStatus(SessionState state, SessionStatus? terminal) + { + SessionStatus activity; + if (terminal is not null) + { + activity = terminal.Value; + } + else if ((state.InputRequests?.Count ?? 0) > 0 || HasPendingToolCallConfirmation(state)) + { + activity = SessionStatus.InputNeeded; + } + else if (state.ActiveTurn is not null) + { + activity = SessionStatus.InProgress; + } + else + { + activity = SessionStatus.Idle; + } + + return (state.Summary.Status & ~StatusActivityMask) | activity; + } + + private static void RefreshSummaryStatus(SessionState state) => + state.Summary.Status = SummaryStatus(state, null); + + private static void TouchModified(SessionState state) => + state.Summary.ModifiedAt = NowMs(); + + // ─── Active-turn helpers ─────────────────────────────────────────────── + + private static ReduceOutcome EndTurn( + SessionState state, + string turnId, + TurnState turnState, + SessionStatus? terminalStatus, + ErrorInfo? errInfo) + { + if (state.ActiveTurn is null || state.ActiveTurn.Id != turnId) + { + return ReduceOutcome.NoOp; + } + + ActiveTurn active = state.ActiveTurn; + state.ActiveTurn = null; + + var parts = new List(active.ResponseParts.Count); + foreach (ResponsePart part in active.ResponseParts) + { + if (part.Value is not ToolCallResponsePart tc) + { + parts.Add(part); + continue; + } + + if (tc.ToolCall.Value is ToolCallCompletedState or ToolCallCancelledState) + { + parts.Add(part); + continue; + } + + ToolCallCommon common = ToolCallMeta(tc.ToolCall); + (StringOrMarkdown invocation, string? toolInput) = ToolCallInvocationAndInput(tc.ToolCall); + var cancelled = new ToolCallCancelledState + { + Status = ToolCallStatus.Cancelled, + ToolCallId = common.Id, + ToolName = common.Name, + DisplayName = common.DisplayName, + Contributor = common.Contributor, + Meta = common.Meta, + InvocationMessage = invocation, + ToolInput = toolInput, + Reason = ToolCallCancellationReason.Skipped, + }; + parts.Add(new ResponsePart(new ToolCallResponsePart + { + Kind = ResponsePartKind.ToolCall, + ToolCall = new ToolCallState(cancelled), + })); + } + + var turn = new Turn + { + Id = active.Id, + Message = active.Message, + ResponseParts = parts, + Usage = active.Usage, + State = turnState, + Error = errInfo, + }; + + state.Turns.Add(turn); + state.InputRequests = null; + TouchModified(state); + state.Summary.Status = SummaryStatus(state, terminalStatus); + return ReduceOutcome.Applied; + } + + private static void UpsertInputRequest(SessionState state, SessionInputRequest req) + { + List existing = state.InputRequests ?? new List(); + int found = existing.FindIndex(r => r.Id == req.Id); + if (found >= 0) + { + req.Answers ??= existing[found].Answers; + existing[found] = req; + } + else + { + existing.Add(req); + } + + state.InputRequests = existing; + state.Summary.Status = SummaryStatus(state, null); + TouchModified(state); + state.Summary.Status = WithStatusFlag(state.Summary.Status, SessionStatus.IsRead, false); + } + + // ─── Customization helpers ───────────────────────────────────────────── + + private static bool TryCustomizationId(Customization c, out string id) + { + switch (c.Value) + { + case PluginCustomization v: + id = v.Id; + return true; + case DirectoryCustomization v: + id = v.Id; + return true; + default: + id = string.Empty; + return false; + } + } + + private static bool TryChildCustomizationId(ChildCustomization c, out string id) + { + switch (c.Value) + { + case AgentCustomization v: id = v.Id; return true; + case SkillCustomization v: id = v.Id; return true; + case PromptCustomization v: id = v.Id; return true; + case RuleCustomization v: id = v.Id; return true; + case HookCustomization v: id = v.Id; return true; + case McpServerCustomization v: id = v.Id; return true; + default: id = string.Empty; return false; + } + } + + private static List? ContainerChildren(Customization c) => c.Value switch + { + PluginCustomization v => v.Children, + DirectoryCustomization v => v.Children, + _ => null, + }; + + private static void SetContainerEnabled(Customization c, bool enabled) + { + switch (c.Value) + { + case PluginCustomization v: v.Enabled = enabled; break; + case DirectoryCustomization v: v.Enabled = enabled; break; + } + } + + private static bool ApplyToggle(List list, string id, bool enabled) + { + foreach (Customization c in list) + { + if (TryCustomizationId(c, out string got) && got == id) + { + SetContainerEnabled(c, enabled); + return true; + } + } + + return false; + } + + // ─── Active-turn mutation helpers ────────────────────────────────────── + + private static ReduceOutcome UpdateToolCall( + SessionState state, + string turnId, + string targetToolCallId, + Func updater) + { + if (state.ActiveTurn is null || state.ActiveTurn.Id != turnId) + { + return ReduceOutcome.NoOp; + } + + List parts = state.ActiveTurn.ResponseParts; + for (int i = 0; i < parts.Count; i++) + { + if (parts[i].Value is not ToolCallResponsePart tc) + { + continue; + } + + if (ToolCallId(tc.ToolCall) == targetToolCallId) + { + tc.ToolCall = updater(tc.ToolCall); + return ReduceOutcome.Applied; + } + } + + return ReduceOutcome.NoOp; + } + + private static ReduceOutcome UpdateResponsePart( + SessionState state, + string turnId, + string partId, + Action updater) + { + if (state.ActiveTurn is null || state.ActiveTurn.Id != turnId) + { + return ReduceOutcome.NoOp; + } + + foreach (ResponsePart part in state.ActiveTurn.ResponseParts) + { + string id = part.Value switch + { + ToolCallResponsePart v => ToolCallId(v.ToolCall), + MarkdownResponsePart v => v.Id, + ReasoningResponsePart v => v.Id, + _ => string.Empty, + }; + + if (id.Length > 0 && id == partId) + { + updater(part); + return ReduceOutcome.Applied; + } + } + + return ReduceOutcome.NoOp; + } + + // ─── Root Reducer ────────────────────────────────────────────────────── + + /// + /// Applies to the in place. + /// Returns for actions that target a + /// different state tree. + /// + public static ReduceOutcome ApplyToRoot(RootState state, StateAction action) + { + switch (action.Value) + { + case RootAgentsChangedAction a: + state.Agents = CopyList(a.Agents)!; + return ReduceOutcome.Applied; + case RootActiveSessionsChangedAction a: + state.ActiveSessions = a.ActiveSessions; + return ReduceOutcome.Applied; + case RootTerminalsChangedAction a: + state.Terminals = CopyList(a.Terminals)!; + return ReduceOutcome.Applied; + case RootConfigChangedAction a: + if (state.Config is null) + { + return ReduceOutcome.NoOp; + } + + state.Config.Values = MergeConfig(state.Config.Values, a.Config, a.Replace); + return ReduceOutcome.Applied; + } + + return ReduceOutcome.OutOfScope; + } + + // Shared config merge for the root and session `configChanged` actions: + // when `replace` is set (or no values exist yet) start fresh, otherwise + // mutate the existing map in place; then overlay the incoming entries. + private static Dictionary MergeConfig( + Dictionary? current, + Dictionary incoming, + bool? replace) + { + Dictionary values = replace == true || current is null + ? new Dictionary(incoming.Count) + : current; + + foreach (KeyValuePair kv in incoming) + { + values[kv.Key] = kv.Value; + } + + return values; + } + + // ─── Session Reducer ─────────────────────────────────────────────────── + + /// + /// Applies to the in + /// place. Returns for actions that + /// target a different state tree. + /// + public static ReduceOutcome ApplyToSession(SessionState state, StateAction action) + { + switch (action.Value) + { + case SessionReadyAction: + state.Lifecycle = SessionLifecycle.Ready; + return ReduceOutcome.Applied; + case SessionCreationFailedAction a: + state.Lifecycle = SessionLifecycle.CreationFailed; + state.CreationError = a.Error; + return ReduceOutcome.Applied; + case SessionTurnStartedAction a: + return ApplyTurnStarted(state, a); + case SessionDeltaAction a: + return UpdateResponsePart(state, a.TurnId, a.PartId, p => + { + if (p.Value is MarkdownResponsePart m) + { + m.Content += a.Content; + } + }); + case SessionResponsePartAction a: + if (state.ActiveTurn is null || state.ActiveTurn.Id != a.TurnId) + { + return ReduceOutcome.NoOp; + } + + state.ActiveTurn.ResponseParts.Add(a.Part); + return ReduceOutcome.Applied; + case SessionTurnCompleteAction a: + return EndTurn(state, a.TurnId, TurnState.Complete, null, null); + case SessionTurnCancelledAction a: + return EndTurn(state, a.TurnId, TurnState.Cancelled, null, null); + case SessionErrorAction a: + return EndTurn(state, a.TurnId, TurnState.Error, SessionStatus.Error, a.Error); + case SessionToolCallStartAction a: + if (state.ActiveTurn is null || state.ActiveTurn.Id != a.TurnId) + { + return ReduceOutcome.NoOp; + } + + state.ActiveTurn.ResponseParts.Add(new ResponsePart(new ToolCallResponsePart + { + Kind = ResponsePartKind.ToolCall, + ToolCall = new ToolCallState(new ToolCallStreamingState + { + Status = ToolCallStatus.Streaming, + ToolCallId = a.ToolCallId, + ToolName = a.ToolName, + DisplayName = a.DisplayName, + Contributor = a.Contributor, + Meta = a.Meta, + }), + })); + return ReduceOutcome.Applied; + case SessionToolCallDeltaAction a: + return ApplyToolCallDelta(state, a); + case SessionToolCallReadyAction a: + return WithRefresh(state, ApplyToolCallReady(state, a)); + case SessionToolCallConfirmedAction a: + return WithRefresh(state, ApplyToolCallConfirmed(state, a)); + case SessionToolCallCompleteAction a: + return WithRefresh(state, ApplyToolCallComplete(state, a)); + case SessionToolCallResultConfirmedAction a: + return WithRefresh(state, ApplyToolCallResultConfirmed(state, a)); + case SessionToolCallContentChangedAction a: + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + if (tc.Value is ToolCallRunningState r) + { + r.Content = CopyList(a.Content)!; + } + + return tc; + }); + case SessionTitleChangedAction a: + state.Summary.Title = a.Title; + TouchModified(state); + return ReduceOutcome.Applied; + case SessionUsageAction a: + if (state.ActiveTurn is null || state.ActiveTurn.Id != a.TurnId) + { + return ReduceOutcome.NoOp; + } + + state.ActiveTurn.Usage = a.Usage; + return ReduceOutcome.Applied; + case SessionReasoningAction a: + return UpdateResponsePart(state, a.TurnId, a.PartId, p => + { + if (p.Value is ReasoningResponsePart r) + { + r.Content += a.Content; + } + }); + case SessionModelChangedAction a: + state.Summary.Model = a.Model; + TouchModified(state); + return ReduceOutcome.Applied; + case SessionAgentChangedAction a: + state.Summary.Agent = a.Agent; + TouchModified(state); + return ReduceOutcome.Applied; + case SessionIsReadChangedAction a: + state.Summary.Status = WithStatusFlag(state.Summary.Status, SessionStatus.IsRead, a.IsRead); + return ReduceOutcome.Applied; + case SessionIsArchivedChangedAction a: + state.Summary.Status = WithStatusFlag(state.Summary.Status, SessionStatus.IsArchived, a.IsArchived); + return ReduceOutcome.Applied; + case SessionActivityChangedAction a: + state.Summary.Activity = a.Activity; + return ReduceOutcome.Applied; + case SessionChangesetsChangedAction a: + state.Changesets = CopyList(a.Changesets); + return ReduceOutcome.Applied; + case SessionConfigChangedAction a: + if (state.Config is null) + { + return ReduceOutcome.NoOp; + } + + state.Config.Values = MergeConfig(state.Config.Values, a.Config, a.Replace); + TouchModified(state); + return ReduceOutcome.Applied; + case SessionMetaChangedAction a: + state.Meta = a.Meta; + return ReduceOutcome.Applied; + case SessionServerToolsChangedAction a: + state.ServerTools = CopyList(a.Tools)!; + return ReduceOutcome.Applied; + case SessionActiveClientChangedAction a: + state.ActiveClient = a.ActiveClient; + return ReduceOutcome.Applied; + case SessionActiveClientToolsChangedAction a: + if (state.ActiveClient is null) + { + return ReduceOutcome.NoOp; + } + + state.ActiveClient.Tools = CopyList(a.Tools)!; + return ReduceOutcome.Applied; + case SessionCustomizationsChangedAction a: + state.Customizations = CopyList(a.Customizations); + return ReduceOutcome.Applied; + case SessionCustomizationToggledAction a: + if (state.Customizations is null) + { + return ReduceOutcome.NoOp; + } + + return ApplyToggle(state.Customizations, a.Id, a.Enabled) + ? ReduceOutcome.Applied + : ReduceOutcome.NoOp; + case SessionCustomizationUpdatedAction a: + return ApplyCustomizationUpdated(state, a); + case SessionCustomizationRemovedAction a: + return ApplyCustomizationRemoved(state, a); + case SessionMcpServerStateChangedAction a: + return ApplyMcpServerStateChanged(state, a); + case SessionTruncatedAction a: + return ApplyTruncated(state, a.TurnId); + case SessionInputRequestedAction a: + UpsertInputRequest(state, a.Request); + return ReduceOutcome.Applied; + case SessionInputAnswerChangedAction a: + return ApplyInputAnswerChanged(state, a); + case SessionInputCompletedAction a: + return ApplyInputCompleted(state, a); + case SessionPendingMessageSetAction a: + return ApplyPendingMessageSet(state, a); + case SessionPendingMessageRemovedAction a: + return ApplyPendingMessageRemoved(state, a); + case SessionQueuedMessagesReorderedAction a: + return ApplyQueuedMessagesReordered(state, a); + } + + return ReduceOutcome.OutOfScope; + } + + private static ReduceOutcome WithRefresh(SessionState state, ReduceOutcome outcome) + { + if (outcome == ReduceOutcome.Applied) + { + RefreshSummaryStatus(state); + } + + return outcome; + } + + private static ReduceOutcome ApplyTurnStarted(SessionState state, SessionTurnStartedAction a) + { + state.ActiveTurn = new ActiveTurn + { + Id = a.TurnId, + Message = a.Message, + ResponseParts = new List(), + }; + state.Summary.Status = SummaryStatus(state, null); + TouchModified(state); + state.Summary.Status = WithStatusFlag(state.Summary.Status, SessionStatus.IsRead, false); + + if (a.QueuedMessageId is { } qmid) + { + if (state.SteeringMessage is not null && state.SteeringMessage.Id == qmid) + { + state.SteeringMessage = null; + } + + if (state.QueuedMessages is not null) + { + state.QueuedMessages.RemoveAll(m => m.Id == qmid); + if (state.QueuedMessages.Count == 0) + { + state.QueuedMessages = null; + } + } + } + + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyToolCallDelta(SessionState state, SessionToolCallDeltaAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + if (tc.Value is not ToolCallStreamingState s) + { + return tc; + } + + string current = s.PartialInput ?? string.Empty; + s.PartialInput = current + a.Content; + if (a.InvocationMessage is not null) + { + s.InvocationMessage = a.InvocationMessage; + } + + return tc; + }); + } + + private static ReduceOutcome ApplyToolCallReady(SessionState state, SessionToolCallReadyAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + ToolCallCommon common = ToolCallMeta(tc); + if (tc.Value is ToolCallStreamingState or ToolCallRunningState) + { + if (a.Confirmed is not null) + { + return new ToolCallState(new ToolCallRunningState + { + Status = ToolCallStatus.Running, + ToolCallId = common.Id, + ToolName = common.Name, + DisplayName = common.DisplayName, + Contributor = common.Contributor, + Meta = common.Meta, + InvocationMessage = a.InvocationMessage, + ToolInput = a.ToolInput, + Confirmed = a.Confirmed.Value, + }); + } + + return new ToolCallState(new ToolCallPendingConfirmationState + { + Status = ToolCallStatus.PendingConfirmation, + ToolCallId = common.Id, + ToolName = common.Name, + DisplayName = common.DisplayName, + Contributor = common.Contributor, + Meta = common.Meta, + InvocationMessage = a.InvocationMessage, + ToolInput = a.ToolInput, + ConfirmationTitle = a.ConfirmationTitle, + Edits = a.Edits, + Editable = a.Editable, + Options = a.Options, + }); + } + + return tc; + }); + } + + private static ConfirmationOption? ResolveSelectedOption(List? options, string? id) + { + if (id is null || options is null) + { + return null; + } + + foreach (ConfirmationOption opt in options) + { + if (opt.Id == id) + { + return opt; + } + } + + return null; + } + + private static ReduceOutcome ApplyToolCallConfirmed(SessionState state, SessionToolCallConfirmedAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + if (tc.Value is not ToolCallPendingConfirmationState s) + { + return tc; + } + + ConfirmationOption? selected = ResolveSelectedOption(s.Options, a.SelectedOptionId); + if (a.Approved) + { + string? toolInput = a.EditedToolInput ?? s.ToolInput; + ToolCallConfirmationReason confirmed = a.Confirmed ?? ToolCallConfirmationReason.NotNeeded; + return new ToolCallState(new ToolCallRunningState + { + Status = ToolCallStatus.Running, + ToolCallId = s.ToolCallId, + ToolName = s.ToolName, + DisplayName = s.DisplayName, + Contributor = s.Contributor, + Meta = s.Meta, + InvocationMessage = s.InvocationMessage, + ToolInput = toolInput, + Confirmed = confirmed, + SelectedOption = selected, + }); + } + + ToolCallCancellationReason reason = a.Reason ?? ToolCallCancellationReason.Denied; + return new ToolCallState(new ToolCallCancelledState + { + Status = ToolCallStatus.Cancelled, + ToolCallId = s.ToolCallId, + ToolName = s.ToolName, + DisplayName = s.DisplayName, + Contributor = s.Contributor, + Meta = s.Meta, + InvocationMessage = s.InvocationMessage, + ToolInput = s.ToolInput, + Reason = reason, + ReasonMessage = a.ReasonMessage, + UserSuggestion = a.UserSuggestion, + SelectedOption = selected, + }); + }); + } + + private static ReduceOutcome ApplyToolCallComplete(SessionState state, SessionToolCallCompleteAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + ToolCallCommon common = ToolCallMeta(tc); + StringOrMarkdown invocation; + string? toolInput; + ToolCallConfirmationReason confirmed = ToolCallConfirmationReason.NotNeeded; + ConfirmationOption? selectedOption = null; + + switch (tc.Value) + { + case ToolCallRunningState v: + invocation = v.InvocationMessage; + toolInput = v.ToolInput; + confirmed = v.Confirmed; + selectedOption = v.SelectedOption; + break; + case ToolCallPendingConfirmationState v: + invocation = v.InvocationMessage; + toolInput = v.ToolInput; + break; + default: + return tc; + } + + bool requiresResultConfirmation = a.RequiresResultConfirmation == true; + if (requiresResultConfirmation) + { + return new ToolCallState(new ToolCallPendingResultConfirmationState + { + Status = ToolCallStatus.PendingResultConfirmation, + ToolCallId = common.Id, + ToolName = common.Name, + DisplayName = common.DisplayName, + Contributor = common.Contributor, + Meta = common.Meta, + InvocationMessage = invocation, + ToolInput = toolInput, + Success = a.Result.Success, + PastTenseMessage = a.Result.PastTenseMessage, + Content = CopyList(a.Result.Content)!, + StructuredContent = a.Result.StructuredContent, + Error = a.Result.Error, + Confirmed = confirmed, + SelectedOption = selectedOption, + }); + } + + return new ToolCallState(new ToolCallCompletedState + { + Status = ToolCallStatus.Completed, + ToolCallId = common.Id, + ToolName = common.Name, + DisplayName = common.DisplayName, + Contributor = common.Contributor, + Meta = common.Meta, + InvocationMessage = invocation, + ToolInput = toolInput, + Success = a.Result.Success, + PastTenseMessage = a.Result.PastTenseMessage, + Content = CopyList(a.Result.Content)!, + StructuredContent = a.Result.StructuredContent, + Error = a.Result.Error, + Confirmed = confirmed, + SelectedOption = selectedOption, + }); + }); + } + + private static ReduceOutcome ApplyToolCallResultConfirmed(SessionState state, SessionToolCallResultConfirmedAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + if (tc.Value is not ToolCallPendingResultConfirmationState s) + { + return tc; + } + + if (a.Approved) + { + return new ToolCallState(new ToolCallCompletedState + { + Status = ToolCallStatus.Completed, + ToolCallId = s.ToolCallId, + ToolName = s.ToolName, + DisplayName = s.DisplayName, + Contributor = s.Contributor, + Meta = s.Meta, + InvocationMessage = s.InvocationMessage, + ToolInput = s.ToolInput, + Success = s.Success, + PastTenseMessage = s.PastTenseMessage, + Content = s.Content, + StructuredContent = s.StructuredContent, + Error = s.Error, + Confirmed = s.Confirmed, + SelectedOption = s.SelectedOption, + }); + } + + return new ToolCallState(new ToolCallCancelledState + { + Status = ToolCallStatus.Cancelled, + ToolCallId = s.ToolCallId, + ToolName = s.ToolName, + DisplayName = s.DisplayName, + Contributor = s.Contributor, + Meta = s.Meta, + InvocationMessage = s.InvocationMessage, + ToolInput = s.ToolInput, + Reason = ToolCallCancellationReason.ResultDenied, + SelectedOption = s.SelectedOption, + }); + }); + } + + private static ReduceOutcome ApplyTruncated(SessionState state, string? turnId) + { + if (turnId is null) + { + state.Turns = new List(); + } + else + { + int idx = state.Turns.FindIndex(t => t.Id == turnId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + state.Turns = state.Turns.GetRange(0, idx + 1); + } + + state.ActiveTurn = null; + state.InputRequests = null; + TouchModified(state); + state.Summary.Status = SummaryStatus(state, null); + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyInputAnswerChanged(SessionState state, SessionInputAnswerChangedAction a) + { + List? list = state.InputRequests; + int idx = list?.FindIndex(r => r.Id == a.RequestId) ?? -1; + if (idx < 0 || list is null) + { + return ReduceOutcome.NoOp; + } + + SessionInputRequest req = list[idx]; + req.Answers ??= new Dictionary(); + if (a.Answer is null) + { + req.Answers.Remove(a.QuestionId); + } + else + { + req.Answers[a.QuestionId] = a.Answer; + } + + if (req.Answers.Count == 0) + { + req.Answers = null; + } + + TouchModified(state); + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyInputCompleted(SessionState state, SessionInputCompletedAction a) + { + List? list = state.InputRequests; + if (list is null) + { + return ReduceOutcome.NoOp; + } + + int before = list.Count; + var next = list.Where(r => r.Id != a.RequestId).ToList(); + if (next.Count == before) + { + return ReduceOutcome.NoOp; + } + + state.InputRequests = next.Count == 0 ? null : next; + RefreshSummaryStatus(state); + TouchModified(state); + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyPendingMessageSet(SessionState state, SessionPendingMessageSetAction a) + { + var entry = new PendingMessage { Id = a.Id, Message = a.Message }; + switch (a.Kind) + { + case PendingMessageKind.Steering: + state.SteeringMessage = entry; + break; + case PendingMessageKind.Queued: + List list = state.QueuedMessages ?? new List(); + int idx = list.FindIndex(m => m.Id == entry.Id); + if (idx >= 0) + { + list[idx] = entry; + } + else + { + list.Add(entry); + } + + state.QueuedMessages = list; + break; + } + + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyPendingMessageRemoved(SessionState state, SessionPendingMessageRemovedAction a) + { + switch (a.Kind) + { + case PendingMessageKind.Steering: + if (state.SteeringMessage is not null && state.SteeringMessage.Id == a.Id) + { + state.SteeringMessage = null; + return ReduceOutcome.Applied; + } + + return ReduceOutcome.NoOp; + case PendingMessageKind.Queued: + List? list = state.QueuedMessages; + if (list is null) + { + return ReduceOutcome.NoOp; + } + + int removed = list.RemoveAll(m => m.Id == a.Id); + if (removed == 0) + { + return ReduceOutcome.NoOp; + } + + state.QueuedMessages = list.Count == 0 ? null : list; + return ReduceOutcome.Applied; + } + + return ReduceOutcome.NoOp; + } + + private static ReduceOutcome ApplyQueuedMessagesReordered(SessionState state, SessionQueuedMessagesReorderedAction a) + { + if (state.QueuedMessages is null) + { + return ReduceOutcome.NoOp; + } + + var byId = new Dictionary(state.QueuedMessages.Count); + foreach (PendingMessage m in state.QueuedMessages) + { + byId[m.Id] = m; + } + + var reordered = new List(byId.Count); + var seen = new HashSet(); + foreach (string id in a.Order) + { + if (byId.TryGetValue(id, out PendingMessage? msg) && seen.Add(id)) + { + reordered.Add(msg); + } + } + + // Append messages absent from `order`, preserving their original order. + foreach (PendingMessage m in state.QueuedMessages) + { + if (!seen.Contains(m.Id)) + { + reordered.Add(m); + } + } + + state.QueuedMessages = reordered; + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyCustomizationUpdated(SessionState state, SessionCustomizationUpdatedAction a) + { + if (!TryCustomizationId(a.Customization, out string actionId)) + { + return ReduceOutcome.NoOp; + } + + List list = state.Customizations ?? new List(); + int idx = -1; + for (int i = 0; i < list.Count; i++) + { + if (TryCustomizationId(list[i], out string got) && got == actionId) + { + idx = i; + break; + } + } + + if (idx >= 0) + { + list[idx] = a.Customization; + } + else + { + list.Add(a.Customization); + } + + state.Customizations = list; + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyCustomizationRemoved(SessionState state, SessionCustomizationRemovedAction a) + { + List? list = state.Customizations; + if (list is null) + { + return ReduceOutcome.NoOp; + } + + for (int i = 0; i < list.Count; i++) + { + if (TryCustomizationId(list[i], out string got) && got == a.Id) + { + list.RemoveAt(i); + return ReduceOutcome.Applied; + } + } + + foreach (Customization c in list) + { + List? children = ContainerChildren(c); + if (children is null) + { + continue; + } + + for (int j = 0; j < children.Count; j++) + { + if (TryChildCustomizationId(children[j], out string childGot) && childGot == a.Id) + { + children.RemoveAt(j); + return ReduceOutcome.Applied; + } + } + } + + return ReduceOutcome.NoOp; + } + + /// + /// Applies a session/mcpServerStateChanged action: a + /// full-replacement of an MCP server customization's + /// and + /// , located by id. + /// + /// Mirrors the canonical TypeScript reducer (and the Go/Rust ports): + /// a top-level entry is matched first + /// (the host MAY surface MCP servers directly at the top level); otherwise + /// the search descends into container children. The action is a no-op when + /// no customization carries the id, or when the matched id belongs to a + /// non-MCP customization type. + /// + private static ReduceOutcome ApplyMcpServerStateChanged(SessionState state, SessionMcpServerStateChangedAction a) + { + List? list = state.Customizations; + if (list is null) + { + return ReduceOutcome.NoOp; + } + + // Top-level entries. McpServerCustomization is a valid top-level + // Customization variant, but it is intentionally absent from the + // container-id helper (TryCustomizationId only knows the Plugin / + // Directory containers), so match it directly here. + foreach (Customization c in list) + { + if (c.Value is McpServerCustomization top && top.Id == a.Id) + { + top.State = a.State; + top.Channel = a.Channel; + return ReduceOutcome.Applied; + } + + // A non-MCP top-level customization that carries the id is a no-op + // (the id targets a customization that is not an MCP server). + if (TryCustomizationId(c, out string topGot) && topGot == a.Id) + { + return ReduceOutcome.NoOp; + } + } + + // Container children. + foreach (Customization c in list) + { + List? children = ContainerChildren(c); + if (children is null) + { + continue; + } + + foreach (ChildCustomization child in children) + { + if (child.Value is McpServerCustomization mcp && mcp.Id == a.Id) + { + mcp.State = a.State; + mcp.Channel = a.Channel; + return ReduceOutcome.Applied; + } + + if (TryChildCustomizationId(child, out string childGot) && childGot == a.Id) + { + // id belongs to a non-MCP child customization → no-op. + return ReduceOutcome.NoOp; + } + } + } + + return ReduceOutcome.NoOp; + } + + // ─── Terminal Reducer ────────────────────────────────────────────────── + + /// + /// Applies to the in + /// place. Returns for actions that + /// target a different state tree. + /// + public static ReduceOutcome ApplyToTerminal(TerminalState state, StateAction action) + { + switch (action.Value) + { + case TerminalDataAction a: + AppendTerminalData(state, a.Data); + return ReduceOutcome.Applied; + case TerminalInputAction: + return ReduceOutcome.NoOp; + case TerminalResizedAction a: + state.Cols = a.Cols; + state.Rows = a.Rows; + return ReduceOutcome.Applied; + case TerminalClaimedAction a: + state.Claim = a.Claim; + return ReduceOutcome.Applied; + case TerminalTitleChangedAction a: + state.Title = a.Title; + return ReduceOutcome.Applied; + case TerminalCwdChangedAction a: + state.Cwd = a.Cwd; + return ReduceOutcome.Applied; + case TerminalExitedAction a: + state.ExitCode = a.ExitCode; + return ReduceOutcome.Applied; + case TerminalClearedAction: + state.Content = new List(); + return ReduceOutcome.Applied; + case TerminalCommandDetectionAvailableAction: + state.SupportsCommandDetection = true; + return ReduceOutcome.Applied; + case TerminalCommandExecutedAction a: + state.Content.Add(new TerminalContentPart(new TerminalCommandPart + { + Type = "command", + CommandId = a.CommandId, + CommandLine = a.CommandLine, + Timestamp = a.Timestamp, + IsComplete = false, + })); + state.SupportsCommandDetection = true; + return ReduceOutcome.Applied; + case TerminalCommandFinishedAction a: + foreach (TerminalContentPart part in state.Content) + { + if (part.Value is TerminalCommandPart c && c.CommandId == a.CommandId) + { + c.IsComplete = true; + c.ExitCode = a.ExitCode; + c.DurationMs = a.DurationMs; + return ReduceOutcome.Applied; + } + } + + return ReduceOutcome.NoOp; + } + + return ReduceOutcome.OutOfScope; + } + + private static void AppendTerminalData(TerminalState state, string data) + { + int n = state.Content.Count; + if (n > 0) + { + switch (state.Content[n - 1].Value) + { + case TerminalCommandPart tail when tail.IsComplete == false: + tail.Output = (tail.Output ?? string.Empty) + data; + return; + case TerminalUnclassifiedPart tail: + tail.Value += data; + return; + } + } + + state.Content.Add(new TerminalContentPart(new TerminalUnclassifiedPart + { + Type = "unclassified", + Value = data, + })); + } + + // ─── Changeset Reducer ───────────────────────────────────────────────── + + /// + /// Applies to the in + /// place. Faithful port of the canonical TypeScript changesetReducer: + /// a stable file order is preserved by appending unknown ids and replacing + /// matching ids in place, and the error payload is carried only while + /// the relevant status is Error so a recovered changeset or operation + /// never keeps a stale error. Returns + /// for actions that target a different state tree. + /// + public static ReduceOutcome ApplyToChangeset(ChangesetState state, StateAction action) + { + switch (action.Value) + { + case ChangesetStatusChangedAction a: + // Carry `error` only when the new status is Error so we don't + // leave a stale error sitting on a recovered changeset. + state.Status = a.Status; + state.Error = a.Status == ChangesetStatus.Error ? a.Error : null; + return ReduceOutcome.Applied; + + case ChangesetFileSetAction a: + { + int idx = state.Files.FindIndex(f => f.Id == a.File.Id); + if (idx < 0) + { + state.Files.Add(a.File); + } + else + { + state.Files[idx] = a.File; + } + + return ReduceOutcome.Applied; + } + + case ChangesetFileRemovedAction a: + { + int idx = state.Files.FindIndex(f => f.Id == a.FileId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + state.Files.RemoveAt(idx); + return ReduceOutcome.Applied; + } + + case ChangesetOperationsChangedAction a: + // Full replacement: a list replaces the previous operations; a + // null list (wire `operations: null`) clears them entirely. + state.Operations = a.Operations; + return ReduceOutcome.Applied; + + case ChangesetOperationStatusChangedAction a: + { + if (state.Operations is null) + { + return ReduceOutcome.NoOp; + } + + int idx = state.Operations.FindIndex(o => o.Id == a.OperationId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + ChangesetOperation op = state.Operations[idx]; + // Carry `error` only when the new status is Error so we don't + // leave a stale error on an operation that recovered or started + // running. + op.Status = a.Status; + op.Error = a.Status == ChangesetOperationStatus.Error ? a.Error : null; + return ReduceOutcome.Applied; + } + + case ChangesetClearedAction: + if (state.Files.Count == 0) + { + return ReduceOutcome.NoOp; + } + + state.Files.Clear(); + return ReduceOutcome.Applied; + } + + return ReduceOutcome.OutOfScope; + } + + // ─── Resource-Watch Reducer ──────────────────────────────────────────── + + /// + /// Applies to the + /// in place. Faithful port of the canonical TypeScript + /// resourceWatchReducer (and the Kotlin/Rust/Go ports): watches are + /// intentionally event-pass-through, so resourceWatch/changed leaves + /// the watch descriptor unchanged (a recognized-but-no-effect + /// ) and the reducer keeps no history of the + /// delivered changes. Every other action targets a different state tree and + /// returns ; both paths leave + /// untouched, matching the canonical reducer's + /// "return state unchanged" for known and unknown actions alike. + /// + public static ReduceOutcome ApplyToResourceWatch(ResourceWatchState state, StateAction action) + { + _ = state; + return action.Value is ResourceWatchChangedAction + ? ReduceOutcome.NoOp + : ReduceOutcome.OutOfScope; + } + + // ─── Annotations Reducer ─────────────────────────────────────────────── + + /// + /// Applies to the in + /// place. Faithful port of the canonical TypeScript annotationsReducer + /// (and the Kotlin/Rust/Go/Swift ports): the dispatch order of annotations + /// (and of entries within an annotation) is preserved — new annotations and + /// entries are appended, a *Set action whose id matches replaces in + /// place, and an action whose target id is unknown is a no-op (mirroring + /// changeset/fileRemoved semantics). The single-entry-minimum + /// invariant is enforced by producers, not the reducer. Returns + /// for actions that target a different + /// state tree. + /// + public static ReduceOutcome ApplyToAnnotations(AnnotationsState state, StateAction action) + { + switch (action.Value) + { + case AnnotationsSetAction a: + { + int idx = state.Annotations.FindIndex(t => t.Id == a.Annotation.Id); + if (idx < 0) + { + state.Annotations.Add(a.Annotation); + } + else + { + state.Annotations[idx] = a.Annotation; + } + + return ReduceOutcome.Applied; + } + + case AnnotationsRemovedAction a: + { + int idx = state.Annotations.FindIndex(t => t.Id == a.AnnotationId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + state.Annotations.RemoveAt(idx); + return ReduceOutcome.Applied; + } + + case AnnotationsEntrySetAction a: + { + int tIdx = state.Annotations.FindIndex(t => t.Id == a.AnnotationId); + if (tIdx < 0) + { + return ReduceOutcome.NoOp; + } + + Annotation annotation = state.Annotations[tIdx]; + int cIdx = annotation.Entries.FindIndex(c => c.Id == a.Entry.Id); + if (cIdx < 0) + { + annotation.Entries.Add(a.Entry); + } + else + { + annotation.Entries[cIdx] = a.Entry; + } + + return ReduceOutcome.Applied; + } + + case AnnotationsEntryRemovedAction a: + { + int tIdx = state.Annotations.FindIndex(t => t.Id == a.AnnotationId); + if (tIdx < 0) + { + return ReduceOutcome.NoOp; + } + + Annotation annotation = state.Annotations[tIdx]; + int cIdx = annotation.Entries.FindIndex(c => c.Id == a.EntryId); + if (cIdx < 0) + { + return ReduceOutcome.NoOp; + } + + annotation.Entries.RemoveAt(cIdx); + return ReduceOutcome.Applied; + } + } + + return ReduceOutcome.OutOfScope; + } + + // ─── Client Dispatchable ─────────────────────────────────────────────── + + /// + /// The set of action wire-type strings a client is allowed to + /// dispatch. Mirrors the Swift client's clientDispatchableActions + /// — the cross-language contract for which actions originate on the client + /// channel rather than host-only. + /// + public static readonly IReadOnlySet ClientDispatchableActions = new HashSet + { + "session/turnStarted", + "session/toolCallConfirmed", + "session/toolCallComplete", + "session/toolCallResultConfirmed", + "session/turnCancelled", + "session/modelChanged", + "session/activeClientChanged", + "session/activeClientToolsChanged", + "session/pendingMessageSet", + "session/pendingMessageRemoved", + "session/queuedMessagesReordered", + "session/inputAnswerChanged", + "session/inputCompleted", + "session/customizationToggled", + "session/isReadChanged", + "session/isArchivedChanged", + }; + + /// + /// Checks whether may be dispatched by a client. + /// The action's wire type is read by serializing it through the real + /// serializer (there is no public accessor for the generated [WireValue] + /// mapping), then tested for membership in . + /// Mirrors the Swift client's isClientDispatchable. + /// + public static bool IsClientDispatchable(StateAction action) + { + using var doc = JsonDocument.Parse(SystemTextJsonAhpSerializer.Default.Serialize(action)); + string? type = doc.RootElement.GetProperty("type").GetString(); + return type is not null && ClientDispatchableActions.Contains(type); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Subscription.cs b/clients/dotnet/src/AgentHostProtocol/Subscription.cs new file mode 100644 index 00000000..cc81446b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Subscription.cs @@ -0,0 +1,214 @@ +// Per-URI subscription handle and top-level event stream — port of the Go +// client's Subscription, SubscriptionEvent, EventStream, ClientEvent types. +// Mirrors: ahp/client.go (SubscriptionEvent variants, Subscription, EventStream). +#nullable enable + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; + +namespace Microsoft.AgentHostProtocol; + +// ─── Subscription events ───────────────────────────────────────────────────── + +/// Marker interface for all subscription event variants. +public abstract class SubscriptionEvent { } + +/// A write-ahead action envelope delivered to a subscription. +public sealed class SubscriptionEventAction : SubscriptionEvent +{ + /// The action envelope from the server. + public ActionEnvelope Envelope { get; } + + /// Creates a new action event. + public SubscriptionEventAction(ActionEnvelope envelope) => Envelope = envelope; +} + +/// Mirrors the root/sessionAdded notification. +public sealed class SubscriptionEventSessionAdded : SubscriptionEvent +{ + /// The notification parameters. + public SessionAddedParams Params { get; } + + /// Creates a new session-added event. + public SubscriptionEventSessionAdded(SessionAddedParams @params) => Params = @params; +} + +/// Mirrors the root/sessionRemoved notification. +public sealed class SubscriptionEventSessionRemoved : SubscriptionEvent +{ + /// The notification parameters. + public SessionRemovedParams Params { get; } + + /// Creates a new session-removed event. + public SubscriptionEventSessionRemoved(SessionRemovedParams @params) => Params = @params; +} + +/// Mirrors the root/sessionSummaryChanged notification. +public sealed class SubscriptionEventSessionSummaryChanged : SubscriptionEvent +{ + /// The notification parameters. + public SessionSummaryChangedParams Params { get; } + + /// Creates a new session-summary-changed event. + public SubscriptionEventSessionSummaryChanged(SessionSummaryChangedParams @params) => Params = @params; +} + +/// Mirrors the auth/required notification. +public sealed class SubscriptionEventAuthRequired : SubscriptionEvent +{ + /// The notification parameters. + public AuthRequiredParams Params { get; } + + /// Creates a new auth-required event. + public SubscriptionEventAuthRequired(AuthRequiredParams @params) => Params = @params; +} + +/// +/// A tagged with the channel URI it was +/// scoped to. Returned by . +/// +public sealed class ClientEvent +{ + /// The channel URI the event belongs to. + public string Channel { get; } + + /// The underlying subscription event. + public SubscriptionEvent Event { get; } + + /// Creates a client event. + public ClientEvent(string channel, SubscriptionEvent @event) + { + Channel = channel; + Event = @event; + } +} + +// ─── Subscription handle ───────────────────────────────────────────────────── + +/// +/// Per-URI fan-out handle returned by and +/// . Drop the handle by calling +/// (or ) or let +/// tear it down. +/// +public sealed class Subscription : IDisposable +{ + private readonly Channel _channel; + private int _closed; + + /// The channel URI this subscription is bound to. + public string Uri { get; } + + /// Creates a new subscription. + internal Subscription(string uri, int bufferCapacity) + { + Uri = uri; + _channel = Channel.CreateBounded( + new BoundedChannelOptions(bufferCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = false, + SingleWriter = false, + }); + } + + /// + /// The reader side of the subscription's event channel. Read from this + /// to receive events as they arrive. + /// + public ChannelReader Events => _channel.Reader; + + /// + /// Stops the subscription locally without notifying the server. + /// Safe to call multiple times. + /// + public void Close() + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) == 0) + { + _channel.Writer.TryComplete(); + } + } + + /// + public void Dispose() => Close(); + + /// + /// Attempts to deliver an event. Drops the event if the channel is full + /// (overflow protection mirrors the Go trySend). + /// + internal void TrySend(SubscriptionEvent ev) + { + if (Volatile.Read(ref _closed) == 1) return; + _channel.Writer.TryWrite(ev); + } +} + +// ─── Top-level event stream ─────────────────────────────────────────────────── + +/// +/// Top-level fan-in receiver over every inbound event from an , +/// tagged with the channel URI. Multiple streams may exist concurrently. +/// Returned by . +/// +public sealed class EventStream : IDisposable +{ + private readonly Channel _channel; + private int _closed; + + /// Creates a new event stream. + internal EventStream(int bufferCapacity) + { + _channel = Channel.CreateBounded( + new BoundedChannelOptions(bufferCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = false, + SingleWriter = false, + }); + } + + /// + /// The reader side of the event stream. Read from this to receive + /// s as they arrive. + /// + public ChannelReader Events => _channel.Reader; + + /// + /// Stops the stream. Safe to call multiple times. + /// + public void Close() + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) == 0) + { + _channel.Writer.TryComplete(); + } + } + + /// + public void Dispose() => Close(); + + /// + /// Attempts to deliver an event. Drops it on full (mirrors Go trySend). + /// + internal void TrySend(ClientEvent ev) + { + if (Volatile.Read(ref _closed) == 1) return; + _channel.Writer.TryWrite(ev); + } +} + +/// +/// The receipt returned by , recording +/// the client-assigned sequence number for the dispatched action. +/// +public sealed class DispatchHandle +{ + /// The client-assigned sequence number. + public long ClientSeq { get; } + + /// Creates a dispatch handle. + public DispatchHandle(long clientSeq) => ClientSeq = clientSeq; +} diff --git a/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs new file mode 100644 index 00000000..e0f7acc6 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs @@ -0,0 +1,69 @@ +#nullable enable + +using System; +using System.Text.Json; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Shared for the Agent Host Protocol. +/// Wire names and converters are declared by attributes on the generated +/// types, so the options carry no naming policy or converter registrations. +/// +public static class AhpJson +{ + /// The canonical serializer options used by the default serializer. + public static readonly JsonSerializerOptions Options = new() + { + // Generated types use explicit [JsonPropertyName]; no naming policy. + PropertyNamingPolicy = null, + // Optional fields opt into omission per-property via + // [JsonIgnore(WhenWritingNull)]; the global default stays Never so + // required fields still serialize their null/zero values. + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, + }; +} + +/// +/// The default , backed by System.Text.Json. This +/// is the swap seam: an alternative serializer (a different engine, or a +/// schema-validating decorator over this one) can be supplied to the client +/// without changing any other code. +/// +public sealed class SystemTextJsonAhpSerializer : IAhpSerializer +{ + private readonly JsonSerializerOptions _options; + + /// Creates the serializer. + /// Override options; defaults to . + public SystemTextJsonAhpSerializer(JsonSerializerOptions? options = null) + { + _options = options ?? AhpJson.Options; + } + + /// A shared, reusable instance using the default options. + public static SystemTextJsonAhpSerializer Default { get; } = new(); + + /// + public string Serialize(T value) => JsonSerializer.Serialize(value, _options); + + /// + public T Deserialize(string json) => + JsonSerializer.Deserialize(json, _options) + ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); + + /// + public T Deserialize(ReadOnlySpan utf8Json) => + JsonSerializer.Deserialize(utf8Json, _options) + ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); + + /// + public JsonRpcMessage DecodeMessage(TransportMessage message) => + message.Frame == TransportFrame.Text + ? Deserialize(message.Text ?? string.Empty) + : Deserialize(message.Binary.Span); + + /// + public TransportMessage EncodeMessage(JsonRpcMessage message) => + TransportMessage.FromText(Serialize(message)); +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj b/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj new file mode 100644 index 00000000..729bbafd --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj @@ -0,0 +1,45 @@ + + + + + net8.0;net9.0 + + Exe + true + + true + false + false + false + + Major + + + + + + + + + + + + + + + + + + diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientIdStoreTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientIdStoreTests.cs new file mode 100644 index 00000000..51a5c014 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientIdStoreTests.cs @@ -0,0 +1,80 @@ +// Port of the F-group client-id-store parity tests. +// Exercises the real InMemoryClientIdStore over real HostId keys — no mocking +// of the store, the IClientIdStore interface, or HostId. +#nullable enable + +using System; +using System.Text.Json; // mirror/client tests that build wire payloads +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class ClientIdStoreTests +{ + // ── F: in-memory round-trip ─────────────────────────────────────────── + + [Fact] + public async Task InMemoryClientIdStore_RoundTrips() + { + var store = new InMemoryClientIdStore(); + + await store.StoreAsync(new HostId("h1"), "cid-1"); + + Assert.Equal("cid-1", await store.LoadAsync(new HostId("h1"))); + // A host that was never stored has no client ID. + Assert.Null(await store.LoadAsync(new HostId("never-stored"))); + } + + // ── F: in-memory overwrite ──────────────────────────────────────────── + + [Fact] + public async Task InMemoryClientIdStore_Overwrites() + { + var store = new InMemoryClientIdStore(); + var host = new HostId("h1"); + + await store.StoreAsync(host, "cid-1"); + await store.StoreAsync(host, "cid-2"); + + // The second store for the same host wins; reads see the latest value. + Assert.Equal("cid-2", await store.LoadAsync(host)); + } + + // ── F: key unreserved pass-through ──────────────────────────────────── + // HostedResourceKey.PercentEscape leaves RFC-3986 unreserved characters + // (ALPHA / DIGIT / - . _ ~) untouched. + [Fact] + public void HostedResourceKey_UnreservedPassThrough() + { + var key = new HostedResourceKey(new HostId("h1"), "abcXYZ-._~0189"); + // The URI component survives verbatim in the stable key (no % anywhere + // in the URI portion). + Assert.Equal("abcXYZ-._~0189", HostedResourceKey.PercentEscape("abcXYZ-._~0189")); + Assert.Contains("abcXYZ-._~0189", key.ToStableKey()); + Assert.DoesNotContain('%', HostedResourceKey.PercentEscape("abcXYZ-._~0189")); + } + + // ── F: key reserved %-escaped ───────────────────────────────────────── + // Reserved/sub-delim/gen-delim characters get percent-escaped (uppercase + // hex), so a URI like "ahp-session:/s1?x=1" can't collide with the key + // delimiter. + [Fact] + public void HostedResourceKey_ReservedPercentEscaped() + { + // ':' -> %3A, '/' -> %2F, '?' -> %3F, '=' -> %3D, ' ' -> %20 + Assert.Equal("%3A", HostedResourceKey.PercentEscape(":")); + Assert.Equal("%2F", HostedResourceKey.PercentEscape("/")); + Assert.Equal("a%3Fb%3Dc", HostedResourceKey.PercentEscape("a?b=c")); + Assert.Equal("x%20y", HostedResourceKey.PercentEscape("x y")); + + // Two distinct URIs that differ only in a reserved char produce distinct + // stable keys (no clobber). + var k1 = new HostedResourceKey(new HostId("h1"), "ahp-session:/s1"); + var k2 = new HostedResourceKey(new HostId("h1"), "ahp-session:/s2"); + Assert.NotEqual(k1.ToStableKey(), k2.ToStableKey()); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs new file mode 100644 index 00000000..7f387c50 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs @@ -0,0 +1,1037 @@ +// Port of clients/go/ahp/client_test.go. +// Uses an in-memory transport pair (two linked channels) to exercise the real +// AhpClient over a real ITransport — no mocking of the client or JSON engine. +#nullable enable + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +// ── In-memory transport pair ────────────────────────────────────────────────── + +/// +/// Paired in-memory transport. The two ends share linked channels so frames +/// flow from one's outbox directly into the other's inbox, exactly as the Go +/// memTransport helper works. +/// +internal sealed class MemTransport : ITransport +{ + private readonly Channel _inbox; + private readonly Channel _outbox; + private readonly CancellationTokenSource _closeCts; + + private MemTransport( + Channel inbox, + Channel outbox, + CancellationTokenSource closeCts) + { + _inbox = inbox; + _outbox = outbox; + _closeCts = closeCts; + } + + /// Creates a linked pair. Frames sent to A appear on B's inbox and vice versa. + public static (MemTransport A, MemTransport B) CreatePair() + { + var a2b = Channel.CreateBounded(new BoundedChannelOptions(16) { FullMode = BoundedChannelFullMode.Wait }); + var b2a = Channel.CreateBounded(new BoundedChannelOptions(16) { FullMode = BoundedChannelFullMode.Wait }); + var cts = new CancellationTokenSource(); // shared — closing either side closes both. + return (new MemTransport(b2a, a2b, cts), new MemTransport(a2b, b2a, cts)); + } + + public async ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closeCts.Token); + try { await _outbox.Writer.WriteAsync(message, linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (_closeCts.IsCancellationRequested) + { throw new AhpTransportException("closed"); } + } + + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closeCts.Token); + try { return await _inbox.Reader.ReadAsync(linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (_closeCts.IsCancellationRequested) + { throw new AhpTransportException("closed"); } + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _closeCts.Cancel(); + _outbox.Writer.TryComplete(); + _inbox.Writer.TryComplete(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => CloseAsync(); +} + +// ── Helper: fake server ─────────────────────────────────────────────────────── + +internal static class FakeServer +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + /// + /// Reads one initialize request and responds with a stub + /// . + /// + public static async Task HandleOneInitialize(MemTransport serverSide, CancellationToken ct = default) + { + var frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + Assert.NotNull(msg.Request); + Assert.Equal("initialize", msg.Request!.Method); + + var result = new InitializeResult { ProtocolVersion = ProtocolVersion.Current }; + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = msg.Request.Id, + Result = JsonDocument.Parse(Ser.Serialize(result)).RootElement, + } + }; + await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +public sealed class ClientTests +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + // ── Request round-trip ──────────────────────────────────────────────── + + [Fact] + public async Task RequestRoundTrip_InitializeReturnsProtocolVersion() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Server goroutine: respond to one initialize request. + var serverTask = Task.Run(() => FakeServer.HandleOneInitialize(serverSide, cts.Token), cts.Token); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var result = await client.InitializeAsync("test-client", cancellationToken: cts.Token); + + Assert.Equal(ProtocolVersion.Current, result.ProtocolVersion); + await serverTask; + } + + // ── Subscription fan-out ────────────────────────────────────────────── + + [Fact] + public async Task SubscriptionFanOut_ActionReachesPerUriAndTopLevel() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var sub = client.AttachSubscription("ahp-session:/s1"); + var stream = client.CreateEventStream(); + + // Push an `action` notification from the "server" side. + var envelope = new ActionEnvelope + { + Channel = "ahp-session:/s1", + ServerSeq = 1, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "Hello", + }), + }; + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "action", + Params = JsonDocument.Parse(Ser.Serialize(envelope)).RootElement, + } + }; + await serverSide.SendAsync(Ser.EncodeMessage(notif), cts.Token); + + // Per-URI subscription receives the action. + using var readSubCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + var subEv = await sub.Events.ReadAsync(readSubCts.Token); + var actionEv = Assert.IsType(subEv); + Assert.Equal(1, actionEv.Envelope.ServerSeq); + + // Top-level stream also receives it. + var clientEv = await stream.Events.ReadAsync(readSubCts.Token); + Assert.Equal("ahp-session:/s1", clientEv.Channel); + Assert.IsType(clientEv.Event); + + sub.Close(); + stream.Close(); + } + + // ── Shutdown fails in-flight request ────────────────────────────────── + + [Fact] + public async Task Shutdown_FailsInFlightRequest() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + // The server reads the request frame but never responds — the request + // stays in-flight until shutdown. + + var client = await AhpClient.ConnectAsync(clientSide); + + var requestTask = Task.Run(async () => + { + try + { + await client.InitializeAsync("x", new[] { ProtocolVersion.Current }); + return (Exception?)null; + } + catch (Exception ex) { return ex; } + }); + + // Deterministically wait until the request frame is actually on the wire + // (so the pending request is registered and truly in-flight) instead of + // racing a fixed 50ms delay, which flaked under load. + using (var recvCts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + await serverSide.ReceiveAsync(recvCts.Token); + await client.ShutdownAsync(TestContext.Current.CancellationToken); + + var err = await requestTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + Assert.NotNull(err); + // Either AhpClientClosedException or AhpRpcException (synthetic shutdown error). + Assert.True( + err is AhpClientClosedException || err is AhpRpcException, + $"Expected AhpClientClosedException or AhpRpcException, got {err?.GetType().Name}: {err?.Message}"); + } + + // ── In-flight request cancellation (parity with Swift) ───────────────── + // Ported from clients/swift/.../AHPClientTests.swift: + // testRequestThrowsCancellationWhenTaskIsCancelled + // testRequestFastFailsWhenTaskAlreadyCancelled + // Each drives the REAL AhpClient over the REAL MemTransport and reads the + // real pending-request bookkeeping (client.PendingRequestCount) — no client + // mocking. The "no id minted / no bytes pushed" claim is asserted against + // the real next-id counter and a real drain of the server transport. + + // Cancelling the caller's token while a request is in flight surfaces an + // OperationCanceledException AND removes the pending entry (1 -> 0), so a + // late server response is harmlessly dropped. + [Fact] + public async Task Request_CancelDuringFlight_ThrowsAndClearsPending() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + await using var client = await AhpClient.ConnectAsync(clientSide); + + // The request gets its own token so we can cancel just this call. The + // client default-timeout is large enough not to fire first. + using var reqCts = new CancellationTokenSource(); + + var requestTask = Task.Run(async () => + { + try + { + await client.InitializeAsync( + "test-client", + new[] { ProtocolVersion.Current }, + cancellationToken: reqCts.Token); + return (Exception?)null; + } + catch (Exception ex) { return ex; } + }); + + // The server reads the request frame (proving the wire bytes were + // pushed) but never responds — the request stays genuinely in flight. + using (var recvCts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + await serverSide.ReceiveAsync(recvCts.Token); + + // Wait until the pending entry is registered (deterministic, not a sleep). + await WaitUntilAsync( + () => client.PendingRequestCount == 1, + because: "the in-flight request must register exactly one pending entry"); + + // Now cancel the caller's token. + reqCts.Cancel(); + + var err = await requestTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + Assert.NotNull(err); + Assert.True( + err is OperationCanceledException, + $"expected OperationCanceledException, got {err?.GetType().Name}: {err?.Message}"); + + // The cancellation cleaned up the pending entry. + await WaitUntilAsync( + () => client.PendingRequestCount == 0, + because: "cancellation must remove the pending entry so a late response is dropped"); + Assert.Equal(0, client.PendingRequestCount); + } + + // A token that is ALREADY cancelled before the request is issued fast-fails + // with OperationCanceledException WITHOUT minting a request id or pushing + // wire bytes — mirroring the Swift `Task.checkCancellation()` fast path. + [Fact] + public async Task Request_PreCancelledToken_FastFailsWithoutMintingIdOrSending() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + await using var client = await AhpClient.ConnectAsync(clientSide); + + // Capture the next id BEFORE the cancelled request: it must be unchanged + // afterwards (no id minted). + var nextIdBefore = client.NextRequestId; + + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await client.InitializeAsync( + "test-client", + new[] { ProtocolVersion.Current }, + cancellationToken: cancelled.Token)); + + // No request id was minted. + Assert.Equal(nextIdBefore, client.NextRequestId); + // No pending entry was registered. + Assert.Equal(0, client.PendingRequestCount); + // No wire bytes were pushed: the server side has nothing to read. + using var drainCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + await Assert.ThrowsAnyAsync( + async () => await serverSide.ReceiveAsync(drainCts.Token)); + } + + // Sanity: the happy path still resolves after the fast-fail guard was added. + [Fact] + public async Task Request_HappyPath_StillResolves() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var serverTask = Task.Run(() => FakeServer.HandleOneInitialize(serverSide, cts.Token), cts.Token); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var result = await client.InitializeAsync("test-client", cancellationToken: cts.Token); + + Assert.Equal(ProtocolVersion.Current, result.ProtocolVersion); + // The resolved request left no pending entry behind. + Assert.Equal(0, client.PendingRequestCount); + await serverTask; + } + + // ── Back-pressure: drop-oldest + laggard fast-forward + no replay ────── + // Parity with clients/typescript/test/async-queue.test.ts + // 'bounded buffer drops oldest and fast-forwards laggards' + // 'reader created after publish does not replay history' + // The .NET back-pressure is the production BoundedChannelFullMode.DropOldest + // on each Subscription's event channel (Subscription.cs). This drives the + // REAL AhpClient + REAL MemTransport with a capacity-2 subscription buffer: + // we overflow a non-reading (laggard) subscription from the server side and + // assert it observes the NEWEST items (oldest dropped, no unbounded buffer), + // and that a subscription attached AFTER the events get no replay. + [Fact] + public async Task Subscription_BoundedBuffer_DropsOldest_FastForwards_NoReplay() + { + const int capacity = 2; + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = AhpClient.Connect( + clientSide, + new ClientConfig { SubscriptionBufferCapacity = capacity }); + + // Laggard: attached but never read until the very end. + var laggard = client.AttachSubscription("ahp-session:/s1"); + // Barrier on a DIFFERENT uri: read to confirm the read loop has drained + // every earlier frame (frames are processed strictly in order). + var barrier = client.AttachSubscription("ahp-session:/barrier"); + + // Push 4 events to the laggard's uri, PAST its capacity of 2. With + // DropOldest, the oldest two (seq 1, 2) are dropped; the laggard ends up + // holding the newest two (seq 3, 4). + for (long seq = 1; seq <= 4; seq++) + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", seq, $"e{seq}"), cts.Token); + // Barrier frame last: once we read it, all 4 prior frames are fanned out. + await serverSide.SendAsync(BuildActionNotification("ahp-session:/barrier", 99, "barrier"), cts.Token); + + using (var readBarrierCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token)) + { + var bev = Assert.IsType(await barrier.Events.ReadAsync(readBarrierCts.Token)); + Assert.Equal(99, bev.Envelope.ServerSeq); + } + + // The laggard buffered at most `capacity` items (no unbounded growth)... + Assert.Equal(capacity, laggard.Events.Count); + + // ...and they are the NEWEST items: seq 3 then 4 (1 and 2 were dropped). + using (var readLagCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token)) + { + var first = Assert.IsType(await laggard.Events.ReadAsync(readLagCts.Token)); + var second = Assert.IsType(await laggard.Events.ReadAsync(readLagCts.Token)); + Assert.Equal(3, first.Envelope.ServerSeq); + Assert.Equal(4, second.Envelope.ServerSeq); + } + + // A subscription attached AFTER the events were delivered gets NO replay + // of the already-fanned-out history (mirrors the TS 'reader created after + // publish does not replay history'). + var lateReader = client.AttachSubscription("ahp-session:/s1"); + using (var lateDrainCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200))) + await Assert.ThrowsAnyAsync( + async () => await lateReader.Events.ReadAsync(lateDrainCts.Token)); + + // A fresh event after attach DOES reach the late reader (it is live, just + // without history) — proving the empty read above was "no replay", not a + // dead subscription. + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", 5, "e5"), cts.Token); + using (var liveCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token)) + { + var live = Assert.IsType(await lateReader.Events.ReadAsync(liveCts.Token)); + Assert.Equal(5, live.Envelope.ServerSeq); + } + + laggard.Close(); + barrier.Close(); + lateReader.Close(); + } + + // ── Done signalled on transport failure ─────────────────────────────── + + [Fact] + public async Task Done_SignalledOnTransportFailure() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + + await using var client = await AhpClient.ConnectAsync(clientSide); + + // Closing the server end propagates as a receive error to the client. + await serverSide.CloseAsync(); + + // Client.Completion should fire within a reasonable time. + await client.Completion.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.NotNull(client.Error); + } + + // ── Idempotent shutdown ─────────────────────────────────────────────── + + [Fact] + public async Task ShutdownIsIdempotent() + { + var (clientSide, _) = MemTransport.CreatePair(); + var client = await AhpClient.ConnectAsync(clientSide); + + // Concurrent shutdowns must not throw. + var tasks = new Task[4]; + for (int i = 0; i < 4; i++) + { + var cap = i; + tasks[cap] = Task.Run(() => client.ShutdownAsync()); + } + await Task.WhenAll(tasks); + } + + // ── Parity batch-a (matrix group D) ──────────────────────────────────── + // Phase-1 parity tests targeting ClientTests.cs. Each exercises the real + // AhpClient over the real MemTransport + real SystemTextJsonAhpSerializer — + // no SUT mocking. The "server" end is a real MemTransport endpoint driven + // by hand: we decode the client's frame with Ser.DecodeMessage and reply + // with a JsonRpc success/error frame via Ser.EncodeMessage. + + /// + /// Reads one request whose method is and replies + /// with a JSON-RPC success response carrying serialized. + /// Returns the decoded request so the caller can assert on it. + /// + private static async Task AnswerOneRequestAsync( + MemTransport serverSide, string expectedMethod, TResult result, CancellationToken ct) + { + var frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + Assert.NotNull(msg.Request); + Assert.Equal(expectedMethod, msg.Request!.Method); + + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = msg.Request.Id, + Result = JsonDocument.Parse(Ser.Serialize(result)).RootElement, + } + }; + await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); + return msg.Request; + } + + /// Builds an `action` notification frame for . + private static TransportMessage BuildActionNotification(string channel, long serverSeq, string title) + { + var envelope = new ActionEnvelope + { + Channel = channel, + ServerSeq = serverSeq, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = title, + }), + }; + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "action", + Params = JsonDocument.Parse(Ser.Serialize(envelope)).RootElement, + } + }; + return Ser.EncodeMessage(notif); + } + + // D: initialize snapshot in result. + [Fact] + public async Task Initialize_SnapshotDeliveredInResult() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Server replies to `initialize` with a result carrying one snapshot. + var initResult = new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, + ServerSeq = 7, + Snapshots = new System.Collections.Generic.List + { + new Snapshot + { + Resource = "ahp-session:/s1", + FromSeq = 7, + State = new SnapshotState + { + Root = new RootState { Agents = new System.Collections.Generic.List() }, + }, + }, + }, + }; + var serverTask = Task.Run( + () => AnswerOneRequestAsync(serverSide, "initialize", initResult, cts.Token), cts.Token); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var result = await client.InitializeAsync( + "test-client", + initialSubscriptions: new[] { "ahp-session:/s1" }, + cancellationToken: cts.Token); + + Assert.Equal(ProtocolVersion.Current, result.ProtocolVersion); + Assert.NotNull(result.Snapshots); + var snapshot = Assert.Single(result.Snapshots); + Assert.Equal("ahp-session:/s1", snapshot.Resource); + Assert.Equal(7, snapshot.FromSeq); + await serverTask; + } + + // D: subscribe round-trip + snapshot. + [Fact] + public async Task Subscribe_RoundTrip_DeliversSnapshot() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var subResult = new SubscribeResult + { + Snapshot = new Snapshot + { + Resource = "ahp-session:/s1", + FromSeq = 3, + State = new SnapshotState + { + Root = new RootState { Agents = new System.Collections.Generic.List() }, + }, + }, + }; + var serverTask = Task.Run( + () => AnswerOneRequestAsync(serverSide, "subscribe", subResult, cts.Token), cts.Token); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var (result, sub) = await client.SubscribeAsync("ahp-session:/s1", cts.Token); + + // The SubscribeResult carries the snapshot... + Assert.NotNull(result.Snapshot); + Assert.Equal("ahp-session:/s1", result.Snapshot!.Resource); + Assert.Equal(3, result.Snapshot.FromSeq); + // ...and the returned Subscription is attached to the same URI. + Assert.Equal("ahp-session:/s1", sub.Uri); + + sub.Close(); + await serverTask; + } + + // D: attachSubscription (no round-trip subscribe request is sent). + [Fact] + public async Task AttachSubscription_DeliversWithoutRoundTrip() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var sub = client.AttachSubscription("ahp-session:/s1"); + + // Push an `action` notification from the server; the attached sub receives it. + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", 1, "Hi"), cts.Token); + + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + var ev = await sub.Events.ReadAsync(readCts.Token); + var actionEv = Assert.IsType(ev); + Assert.Equal(1, actionEv.Envelope.ServerSeq); + + // No subscribe request must have been sent: the server side has no frame waiting. + // Drain attempt with a short timeout — a frame here would mean a stray request. + using var drainCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + await Assert.ThrowsAnyAsync( + async () => await serverSide.ReceiveAsync(drainCts.Token)); + + sub.Close(); + } + + // D: multi-sub same uri — both subscriptions on one URI receive the event. + [Fact] + public async Task MultipleSubscriptions_SameUri_EachReceiveEvent() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var sub1 = client.AttachSubscription("ahp-session:/s1"); + var sub2 = client.AttachSubscription("ahp-session:/s1"); + + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", 9, "Both"), cts.Token); + + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + var ev1 = Assert.IsType(await sub1.Events.ReadAsync(readCts.Token)); + var ev2 = Assert.IsType(await sub2.Events.ReadAsync(readCts.Token)); + Assert.Equal(9, ev1.Envelope.ServerSeq); + Assert.Equal(9, ev2.Envelope.ServerSeq); + + sub1.Close(); + sub2.Close(); + } + + // D: unsubscribe finishes stream — the subscription's channel completes. + [Fact] + public async Task Unsubscribe_FinishesStream() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Drain the `unsubscribe` notification the client sends so the writer never blocks. + var serverTask = Task.Run(async () => + { + var frame = await serverSide.ReceiveAsync(cts.Token).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + Assert.NotNull(msg.Notification); + Assert.Equal("unsubscribe", msg.Notification!.Method); + }, cts.Token); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var sub = client.AttachSubscription("ahp-session:/s1"); + + await client.UnsubscribeAsync("ahp-session:/s1", cts.Token); + + // The subscription channel is completed: ReadAllAsync finishes with no items, + // and a direct ReadAsync throws ChannelClosedException. + var received = 0; + await foreach (var _ in sub.Events.ReadAllAsync(cts.Token)) + received++; + Assert.Equal(0, received); + await Assert.ThrowsAsync( + async () => await sub.Events.ReadAsync(cts.Token)); + + await serverTask; + } + + // D: dispatch clientSeq — DispatchAsync emits a dispatchAction notif whose + // clientSeq matches the returned DispatchHandle.ClientSeq. + [Fact] + public async Task Dispatch_EmitsActionNotification_WithClientSeq() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "Dispatched", + }); + var handle = await client.DispatchAsync("ahp-session:/s1", action, cancellationToken: cts.Token); + + // The server reads the emitted frame and decodes the dispatchAction notification. + var frame = await serverSide.ReceiveAsync(cts.Token); + var msg = Ser.DecodeMessage(frame); + Assert.NotNull(msg.Notification); + Assert.Equal("dispatchAction", msg.Notification!.Method); + Assert.NotNull(msg.Notification.Params); + var dispatched = Ser.Deserialize(msg.Notification.Params.Value.GetRawText()); + Assert.Equal("ahp-session:/s1", dispatched.Channel); + Assert.Equal(handle.ClientSeq, dispatched.ClientSeq); + } + + // D: json-rpc error -> exception. A JsonRpcErrorResponse maps to AhpRpcException + // carrying the same code. + [Fact] + public async Task RequestError_MapsToAhpRpcException() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var serverTask = Task.Run(async () => + { + var frame = await serverSide.ReceiveAsync(cts.Token).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + Assert.NotNull(msg.Request); + var response = new JsonRpcMessage + { + ErrorResponse = new JsonRpcErrorResponse + { + Id = msg.Request!.Id, + Error = new JsonRpcErrorObject { Code = -32601, Message = "method not found" }, + } + }; + await serverSide.SendAsync(Ser.EncodeMessage(response), cts.Token).ConfigureAwait(false); + }, cts.Token); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var ex = await Assert.ThrowsAsync( + async () => await client.InitializeAsync("x", cancellationToken: cts.Token)); + Assert.Equal(-32601, ex.Code); + + await serverTask; + } + + // D: request timeout — a short DefaultRequestTimeout with no server reply throws. + [Fact] + public async Task Request_Timeout_ThrowsRpcTimeout() + { + var (clientSide, _) = MemTransport.CreatePair(); + // No server reply — the request must time out via the configured default timeout. + var client = AhpClient.Connect( + clientSide, + new ClientConfig { DefaultRequestTimeout = TimeSpan.FromMilliseconds(50) }); + + // RequestAsync's timeout path cancels the linked token, surfacing an + // OperationCanceledException (TaskCanceledException derives from it). + await Assert.ThrowsAnyAsync( + async () => await client.InitializeAsync("x")); + + await client.ShutdownAsync(); + } + + // D: inbound binary frame — a binary transport frame is decoded (not dropped) + // and fanned out to subscribers. + [Fact] + public async Task InboundBinaryFrame_Decoded() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + var sub = client.AttachSubscription("ahp-session:/s1"); + + // Build the same `action` notification as UTF-8 bytes and send it as a BINARY frame. + var textFrame = BuildActionNotification("ahp-session:/s1", 42, "Binary"); + Assert.NotNull(textFrame.Text); + var bytes = System.Text.Encoding.UTF8.GetBytes(textFrame.Text!); + await serverSide.SendAsync(TransportMessage.FromBinary(bytes), cts.Token); + + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + var ev = Assert.IsType(await sub.Events.ReadAsync(readCts.Token)); + Assert.Equal(42, ev.Envelope.ServerSeq); + + sub.Close(); + } + + // D: post-shutdown throws — operations after ShutdownAsync throw AhpClientClosedException. + [Fact] + public async Task PostShutdown_Operations_ThrowClientClosed() + { + var (clientSide, _) = MemTransport.CreatePair(); + var client = await AhpClient.ConnectAsync(clientSide); + + await client.ShutdownAsync(); + + await Assert.ThrowsAsync( + async () => await client.RequestAsync("initialize", null)); + await Assert.ThrowsAsync( + async () => await client.InitializeAsync("x")); + await Assert.ThrowsAsync( + async () => await client.NotifyAsync("ping", null)); + } + + // D: server req -> MethodNotFound. + // With no ServerRequestHandler installed, an inbound server-initiated request + // is answered with a JSON-RPC MethodNotFound (-32601) error, not dropped. + [Fact] + public async Task ServerRequest_NoHandler_RepliesMethodNotFound() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + // (no SetServerRequestHandler call) + + // Server sends a request (note: it HAS an id -> it's a request, not a notif). + var req = new JsonRpcMessage + { + Request = new JsonRpcRequest { Id = 99, Method = "permission/request", Params = null }, + }; + await serverSide.SendAsync(Ser.EncodeMessage(req), cts.Token); + + // The client replies with an error frame carrying the same id + -32601. + var replyFrame = await serverSide.ReceiveAsync(cts.Token); + var reply = Ser.DecodeMessage(replyFrame); + Assert.NotNull(reply.ErrorResponse); + Assert.Equal(99UL, reply.ErrorResponse!.Id); + Assert.Equal(JsonRpcErrorCodes.MethodNotFound, reply.ErrorResponse.Error.Code); + } + + // D: server req -> handler result. + // With a ServerRequestHandler installed, the client replies with the handler's + // result for an inbound server-initiated request. + [Fact] + public async Task ServerRequest_Handler_RepliesResult() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = await AhpClient.ConnectAsync(clientSide); + client.SetServerRequestHandler((method, @params) => + Task.FromResult(new { ok = true, echoed = method })); + + var req = new JsonRpcMessage + { + Request = new JsonRpcRequest { Id = 7, Method = "permission/request", Params = null }, + }; + await serverSide.SendAsync(Ser.EncodeMessage(req), cts.Token); + + var replyFrame = await serverSide.ReceiveAsync(cts.Token); + var reply = Ser.DecodeMessage(replyFrame); + Assert.NotNull(reply.SuccessResponse); + Assert.Equal(7UL, reply.SuccessResponse!.Id); + // The handler's result object is serialized into the reply. + var resultJson = reply.SuccessResponse.Result.GetRawText(); + Assert.Contains("\"ok\":true", resultJson); + Assert.Contains("permission/request", resultJson); + } + + // ── Parity batch P2-A (matrix group D): connection-state + keep-alive ─── + // Ported from the Swift AHPClientTests (clients/swift/.../AHPClientTests.swift): + // testKeepAlivePingsCapableTransport -> KeepAlive_PingsWhenCapable + // testKeepAliveDisabledDoesNotPing -> KeepAlive_DisabledByConfig + // testKeepAliveFailureDisconnectsClient -> KeepAlive_DisconnectsOnPingFailure + // testShutdownTerminatesAllStreams (state assertions) + // -> ConnectionState_TransitionsThroughStateChanges + // + // Each drives the REAL AhpClient. The ping tests use PingCountingTransport — a + // genuine ITransport + IKeepAliveTransport implementation that counts real + // SendPingAsync calls (the .NET equivalent of Swift's `PingCountingTransport` + // actor), NOT a mock of the client or a mocking-framework stub. + + /// + /// Polls until it returns or + /// elapses. Mirrors the Swift test helper + /// waitUntil: a deterministic alternative to a fixed sleep. Throws on + /// timeout so a never-satisfied condition fails the test loudly. + /// + private static async Task WaitUntilAsync( + Func condition, TimeSpan? timeout = null, string? because = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2)); + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(5).ConfigureAwait(false); + } + if (condition()) return; + throw new Xunit.Sdk.XunitException( + $"WaitUntilAsync timed out after {(timeout ?? TimeSpan.FromSeconds(2)).TotalMilliseconds}ms" + + (because is null ? "" : $": {because}")); + } + + // D: connectionState/stateChanges — the client is Connected from construction + // and transitions to Disconnected on shutdown, fanning the transition out to + // every attached StateChangeStream before completing it. Mirrors the Swift + // `testShutdownTerminatesAllStreams` state assertions (`lastState == .disconnected`). + [Fact] + public async Task ConnectionState_TransitionsThroughStateChanges() + { + var (clientSide, _) = MemTransport.CreatePair(); + var client = await AhpClient.ConnectAsync(clientSide); + + // The read/write loops start at construction, so the client is Connected. + Assert.Equal(ConnectionState.Connected, client.ConnectionState); + + // Attach a state-change stream BEFORE shutdown so it observes the transition. + var states = client.CreateStateChangeStream(); + + await client.ShutdownAsync(); + + // The synchronous accessor reflects the terminal state. + Assert.Equal(ConnectionState.Disconnected, client.ConnectionState); + + // Draining the stream yields the Connected->Disconnected transition: the + // stream delivers the final Disconnected then completes, so the last item is + // Disconnected. (Connected was the pre-attachment value, available only via + // the synchronous accessor — the stream carries future transitions only.) + ConnectionState? lastState = null; + var transitions = 0; + using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await foreach (var state in states.States.ReadAllAsync(drainCts.Token)) + { + lastState = state; + transitions++; + } + Assert.Equal(ConnectionState.Disconnected, lastState); + Assert.Equal(1, transitions); + } + + // D: keep-alive pings — with a ping policy and a ping-capable transport, the + // client sends periodic pings. Mirrors Swift `testKeepAlivePingsCapableTransport`. + [Fact] + public async Task KeepAlive_PingsWhenCapable() + { + var transport = new PingCountingTransport(); + var client = AhpClient.Connect( + transport, + new ClientConfig + { + KeepAlive = KeepAlivePolicy.Enabled( + interval: TimeSpan.FromMilliseconds(10), + timeout: TimeSpan.FromMilliseconds(10)), + }); + + // The ping loop runs from construction; wait until it has pinged at least + // twice (proving the loop repeats, not just fires once). + await WaitUntilAsync( + () => transport.PingCount >= 2, + because: "keep-alive loop should issue repeated pings on a capable transport"); + + Assert.True(transport.PingCount >= 2, $"expected >=2 pings, got {transport.PingCount}"); + + await client.ShutdownAsync(); + } + + // D: keep-alive disabled — with KeepAlivePolicy.Disabled the client never pings, + // even on a ping-capable transport. Mirrors Swift `testKeepAliveDisabledDoesNotPing`. + [Fact] + public async Task KeepAlive_DisabledByConfig() + { + var transport = new PingCountingTransport(); + var client = AhpClient.Connect( + transport, + new ClientConfig { KeepAlive = KeepAlivePolicy.Disabled }); + + await Task.Delay(50); + + Assert.Equal(0, transport.PingCount); + + await client.ShutdownAsync(); + } + + // D: keep-alive ping failure — a failed ping is treated as a transport failure: + // the client tears down (ConnectionState -> Disconnected) and the transport is + // closed exactly once. Mirrors Swift `testKeepAliveFailureDisconnectsClient`. + [Fact] + public async Task KeepAlive_DisconnectsOnPingFailure() + { + var transport = new PingCountingTransport(failPing: true); + var client = AhpClient.Connect( + transport, + new ClientConfig + { + KeepAlive = KeepAlivePolicy.Enabled( + interval: TimeSpan.FromMilliseconds(10), + timeout: TimeSpan.FromMilliseconds(10)), + }); + + // The first ping throws; the client must observe that as a transport failure + // and transition to Disconnected. + await WaitUntilAsync( + () => client.ConnectionState == ConnectionState.Disconnected, + because: "a ping failure should tear the client down"); + + Assert.Equal(ConnectionState.Disconnected, client.ConnectionState); + // The teardown closes the transport exactly once. + Assert.Equal(1, transport.CloseCount); + Assert.NotNull(client.Error); + } +} + +// ── Ping-counting transport (real ITransport + IKeepAliveTransport) ───────────── + +/// +/// A real in-memory transport that counts calls and can +/// optionally fail every ping. Port of the Swift test double +/// PingCountingTransport (an actor conforming to +/// AHPKeepAliveTransport). This is a genuine +/// implementation exercised by the real — NOT a mock of the +/// client or a mocking-framework stub. +/// +/// parks until is called, then +/// reports a clean close by throwing (the .NET +/// equivalent of Swift's recv() returning nil). +/// is a no-op while open; the keep-alive tests never push wire frames. +/// +/// +internal sealed class PingCountingTransport : IKeepAliveTransport +{ + private readonly bool _failPing; + private readonly TaskCompletionSource _closedTcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _pings; + private int _closes; + private int _closed; + + public PingCountingTransport(bool failPing = false) => _failPing = failPing; + + /// The number of calls observed so far. + public int PingCount => Volatile.Read(ref _pings); + + /// The number of times transitioned to closed. + public int CloseCount => Volatile.Read(ref _closes); + + public ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _closed) == 1) throw new AhpTransportException("closed"); + return ValueTask.CompletedTask; + } + + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _closed) == 1) throw new TransportClosedException(); + // Park until the transport is closed, then signal a clean close. The keep-alive + // tests drive the client purely through the ping loop, so no inbound frames arrive. + await _closedTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + throw new TransportClosedException(); + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) == 0) + { + Interlocked.Increment(ref _closes); + _closedTcs.TrySetResult(); + } + return ValueTask.CompletedTask; + } + + public ValueTask SendPingAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _closed) == 1) throw new AhpTransportException("closed"); + Interlocked.Increment(ref _pings); + if (_failPing) throw new AhpTransportException("io", "ping failed"); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => CloseAsync(); +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/CrossImplementationConvergenceTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/CrossImplementationConvergenceTests.cs new file mode 100644 index 00000000..1c904277 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/CrossImplementationConvergenceTests.cs @@ -0,0 +1,48 @@ +#nullable enable + +using System.IO; +using System.Text.Json; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +/// +/// Cross-implementation convergence: replays a real session trace produced by an +/// INDEPENDENT AHP host (a separate WebSocket host running the canonical +/// TypeScript sessionReducer) through the C# reducer and asserts the +/// resulting state is byte-for-byte identical to the host's authoritative state. +/// +/// The trace under interop/ was captured over a real WebSocket; this test +/// replays it offline so it runs in CI with no external dependency. It is +/// complementary to the shared per-action fixtures: this is a multi-action +/// session exercising the serverSeq + host-authoritative modifiedAt +/// overlay model (microsoft/agent-host-protocol#186). +/// +public sealed class CrossImplementationConvergenceTests +{ + [Fact] + public void ConvergesWithCapturedCanonicalHostTrace() + { + var path = Path.Combine(System.AppContext.BaseDirectory, "interop", "independent-host-session-convergence.json"); + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var root = doc.RootElement; + var opts = AhpJson.Options; + + var state = root.GetProperty("initial").Deserialize(opts)!; + foreach (var env in root.GetProperty("envelopes").EnumerateArray()) + { + var action = env.GetProperty("action").Deserialize(opts)!; + Reducers.ApplyToSession(state, action); + + // Host-authoritative modifiedAt overlay — the same step every AHP + // client mirror applies so the impure reducer's clock converges. + if (env.TryGetProperty("meta", out var meta) && meta.ValueKind == JsonValueKind.Object + && meta.TryGetProperty("modifiedAt", out var m) && m.ValueKind == JsonValueKind.Number) + state.Summary.ModifiedAt = m.GetInt64(); + } + + var got = JsonCanon.Of(JsonSerializer.SerializeToElement(state, opts)); + var want = JsonCanon.Of(root.GetProperty("final")); + Assert.Equal(want, got); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs new file mode 100644 index 00000000..40a0e18d --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs @@ -0,0 +1,178 @@ +// Port of the F-group FileClientIdStore parity tests +// (clients/swift/.../Tests/AgentHostProtocolClientTests/FileClientIdStoreTests.swift). +// +// Exercises the REAL FileClientIdStore against a REAL temp filesystem directory +// — no mocking of System.IO, the store, or the IClientIdStore interface. The +// store's entire contract is real-file persistence, so a real temp dir is the +// only meaningful test surface (mirrors Swift, which uses a real temp dir). +#nullable enable + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class FileClientIdStoreTests : IDisposable +{ + // A unique temp directory per test instance; removed on Dispose. The store + // itself creates this directory lazily on first write (we don't pre-create + // it, mirroring Swift's "directory is created on first write" contract). + private readonly string _tempDir = + Path.Combine(Path.GetTempPath(), "ahp-file-client-id-store-tests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + // ── F: round-trip + survives across instances ───────────────────────────── + // Swift: testStoreAndLoadRoundTrips + testSurvivesAcrossInstances + // (also folds in testLoadReturnsNilForUnknownHost + testStoreOverwrites). + [Fact] + public async Task FileClientIdStore_RoundTripsAndSurvivesInstances() + { + var writer = new FileClientIdStore(_tempDir); + + // Unknown host before any write has no stored id. + Assert.Null(await writer.LoadAsync(new HostId("alpha"))); + + // Store then load within the same instance round-trips the value. + await writer.StoreAsync(new HostId("alpha"), "abc-123"); + Assert.Equal("abc-123", await writer.LoadAsync(new HostId("alpha"))); + + // Overwrite: the second store for the same host wins. + await writer.StoreAsync(new HostId("alpha"), "abc-456"); + Assert.Equal("abc-456", await writer.LoadAsync(new HostId("alpha"))); + + // Survives across instances: a fresh store rooted at the SAME directory + // (simulating a process restart) reads the persisted value back. + var reader = new FileClientIdStore(_tempDir); + Assert.Equal("abc-456", await reader.LoadAsync(new HostId("alpha"))); + } + + // ── F: per-host keying ──────────────────────────────────────────────────── + // Swift: testStoresAreKeyedPerHost + [Fact] + public async Task FileClientIdStore_KeysPerHost() + { + var store = new FileClientIdStore(_tempDir); + + await store.StoreAsync(new HostId("a"), "id-a"); + await store.StoreAsync(new HostId("b"), "id-b"); + + // Each host keeps its own value — storing "b" doesn't clobber "a". + Assert.Equal("id-a", await store.LoadAsync(new HostId("a"))); + Assert.Equal("id-b", await store.LoadAsync(new HostId("b"))); + } + + // ── F: url-unsafe host id is persisted ──────────────────────────────────── + // Swift: testHostIdWithUrlUnsafeCharactersIsPersisted + [Fact] + public async Task FileClientIdStore_HandlesUrlUnsafeId() + { + var store = new FileClientIdStore(_tempDir); + // Contains ':' '/' ' ' '?' '=' — none of which are filesystem-safe, so + // the store must encode them into a stable, safe filename and still + // round-trip the value. + var trickyId = new HostId("copilot://tunnel/foo bar?baz=1"); + + await store.StoreAsync(trickyId, "tricky-id"); + + Assert.Equal("tricky-id", await store.LoadAsync(trickyId)); + // A distinct (but similar) id must not collide with the first. + var otherId = new HostId("copilot://tunnel/foo bar?baz=2"); + await store.StoreAsync(otherId, "other-id"); + Assert.Equal("other-id", await store.LoadAsync(otherId)); + Assert.Equal("tricky-id", await store.LoadAsync(trickyId)); + } + + // ── F: concurrent writes don't corrupt ──────────────────────────────────── + // Swift: testConcurrentStoresDoNotCorrupt + [Fact] + public async Task FileClientIdStore_ConcurrentWrites_NoCorruption() + { + var store = new FileClientIdStore(_tempDir); + + // Fan out 32 parallel stores to distinct hosts. Atomic writes + the + // serialising gate guarantee every write lands intact and none is lost + // or half-written. + var writes = new Task[32]; + for (var i = 0; i < writes.Length; i++) + { + var n = i; + writes[n] = Task.Run(() => store.StoreAsync(new HostId($"h-{n}"), $"id-{n}")); + } + await Task.WhenAll(writes); + + for (var i = 0; i < writes.Length; i++) + { + var value = await store.LoadAsync(new HostId($"h-{i}")); + Assert.Equal($"id-{i}", value); + } + } + + // ── F: file is owner-only on Unix ───────────────────────────────────────── + // Swift: testFileIsRestrictedToOwnerWhenPossible. On non-Unix the perm + // check is a no-op (the store still ran + persisted), mirroring Swift's + // "WhenPossible" — the round-trip below proves the write happened either way. + [Fact] + public async Task FileClientIdStore_FileIsOwnerOnlyOnUnix() + { + var store = new FileClientIdStore(_tempDir); + await store.StoreAsync(new HostId("h"), "value"); + + // The value persisted regardless of platform. + Assert.Equal("value", await store.LoadAsync(new HostId("h"))); + + // On Unix, the persisted file is restricted to owner read/write (0o600). + if (!OperatingSystem.IsWindows()) + { + var path = Path.Combine(_tempDir, "h.clientid"); + Assert.True(File.Exists(path), $"expected persisted file at {path}"); + var mode = File.GetUnixFileMode(path); + // Mask to the permission bits and assert exactly owner read+write. + var permBits = mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, permBits); + } + } + + // ── F: directory path is actually a file ────────────────────────────────── + // .NET-specific sub-case (Swift's FileClientIdStore swallows directory errors + // via `try?` in ensureDirectory; the .NET port surfaces them loudly instead). + // When the configured store directory is an EXISTING regular file, + // EnsureDirectory() -> Directory.CreateDirectory() throws IOException; + // StoreAsync propagates it (the throw happens before the temp-file + // try/catch). A real temp FILE stands in for the bad "directory" so we + // exercise the real FS + real store, no mocking. + [Fact] + public async Task FileClientIdStore_DirectoryPathIsFile_StoreThrows() + { + // Use a sibling path under the test temp root that we create AS A FILE, + // then point the store at it. (_tempDir itself is cleaned up on Dispose; + // this file lives inside it so it's cleaned up too.) + Directory.CreateDirectory(_tempDir); + var fileAsDir = Path.Combine(_tempDir, "not-a-directory"); + await File.WriteAllTextAsync(fileAsDir, "i am a file, not a directory"); + + var store = new FileClientIdStore(fileAsDir); + + // Storing forces EnsureDirectory(), which can't create a directory where + // a file already exists — the store surfaces this as an IOException + // rather than silently dropping the write. + var ex = await Record.ExceptionAsync( + async () => await store.StoreAsync(new HostId("h"), "value")); + + Assert.NotNull(ex); + Assert.IsAssignableFrom(ex); + + // The stand-in file is untouched (the store didn't clobber it with a + // half-written client-id payload). + Assert.Equal("i am a file, not a directory", await File.ReadAllTextAsync(fileAsDir)); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs new file mode 100644 index 00000000..f27c2922 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs @@ -0,0 +1,204 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +/// +/// Loads every fixture under types/test-cases/reducers/*.json, applies +/// the actions through the matching C# reducer, and compares the resulting +/// state with the fixture's expected output. This is the primary +/// cross-language parity gate for the reducers — the same vectors drive the +/// Rust, Go, Kotlin, Swift, and TypeScript clients. +/// +public sealed class FixtureDrivenReducerTests +{ + // Deterministic timestamp so `summary.modifiedAt` matches what the + // TypeScript reference reducer stamps in the fixtures. + private const long MockNow = 9999; + + private static readonly JsonSerializerOptions Options = AhpJson.Options; + + public static IEnumerable Fixtures() + { + string dir = FindFixtureDir(); + foreach (string path in Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal)) + { + yield return new object[] { Path.GetFileName(path), path }; + } + } + + [Theory] + [MemberData(nameof(Fixtures))] + public void ReducerMatchesFixture(string name, string path) + { + _ = name; + Reducers.SetNowProvider(() => MockNow); + try + { + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement root = doc.RootElement; + string reducer = root.GetProperty("reducer").GetString()!; + JsonElement initial = root.GetProperty("initial"); + JsonElement expected = root.GetProperty("expected"); + var actions = new List(); + foreach (JsonElement actionElement in root.GetProperty("actions").EnumerateArray()) + { + actions.Add(actionElement.Deserialize(Options)!); + } + + switch (reducer) + { + case "root": + RunFixture(initial, expected, actions, Reducers.ApplyToRoot); + break; + case "session": + RunFixture(initial, expected, actions, Reducers.ApplyToSession); + break; + case "terminal": + RunFixture(initial, expected, actions, Reducers.ApplyToTerminal); + break; + case "changeset": + RunFixture(initial, expected, actions, Reducers.ApplyToChangeset); + break; + case "resourceWatch": + RunFixture(initial, expected, actions, Reducers.ApplyToResourceWatch); + break; + case "annotations": + RunFixture(initial, expected, actions, Reducers.ApplyToAnnotations); + break; + default: + throw new Xunit.Sdk.XunitException($"unknown reducer kind '{reducer}'"); + } + } + finally + { + Reducers.SetNowProvider(null); + } + } + + private static void RunFixture( + JsonElement initial, + JsonElement expected, + List actions, + Func apply) + where T : class + { + T state = initial.Deserialize(Options)!; + + // Round-trip the initial state through serialize/deserialize to catch + // any data loss in the generated types before we mutate. + string reSerialized = JsonSerializer.Serialize(state, Options); + using (JsonDocument roundTripped = JsonDocument.Parse(reSerialized)) + { + string actual = Canon(roundTripped.RootElement); + string original = Canon(initial); + Assert.True( + actual == original, + $"initial state did not survive round-trip:\nre-serialized: {actual}\noriginal: {original}"); + } + + foreach (StateAction action in actions) + { + apply(state, action); + } + + string got = Canon(JsonDocument.Parse(JsonSerializer.Serialize(state, Options)).RootElement); + string want = Canon(expected); + Assert.True(got == want, $"state mismatch:\nactual: {got}\nexpected: {want}"); + } + + /// + /// Produces a canonical string for a JSON value: object keys are sorted and + /// null-valued keys are dropped (matching the Go/TS harnesses' null + /// stripping, where an omitted optional field equals an explicit null). + /// + private static string Canon(JsonElement element) + { + var sb = new StringBuilder(); + CanonInto(element, sb); + return sb.ToString(); + } + + private static void CanonInto(JsonElement element, StringBuilder sb) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + sb.Append('{'); + bool first = true; + foreach (JsonProperty prop in element.EnumerateObject() + .Where(p => p.Value.ValueKind != JsonValueKind.Null) + .OrderBy(p => p.Name, StringComparer.Ordinal)) + { + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append(JsonSerializer.Serialize(prop.Name)).Append(':'); + CanonInto(prop.Value, sb); + } + + sb.Append('}'); + break; + case JsonValueKind.Array: + sb.Append('['); + bool firstItem = true; + foreach (JsonElement item in element.EnumerateArray()) + { + if (!firstItem) + { + sb.Append(','); + } + + firstItem = false; + CanonInto(item, sb); + } + + sb.Append(']'); + break; + case JsonValueKind.String: + sb.Append(JsonSerializer.Serialize(element.GetString())); + break; + case JsonValueKind.Number: + sb.Append(element.GetRawText()); + break; + case JsonValueKind.True: + sb.Append("true"); + break; + case JsonValueKind.False: + sb.Append("false"); + break; + default: + sb.Append("null"); + break; + } + } + + private static string FindFixtureDir() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + string candidate = Path.Combine(dir, "types", "test-cases", "reducers"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + + throw new DirectoryNotFoundException( + "could not locate types/test-cases/reducers walking upward from the test assembly"); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs new file mode 100644 index 00000000..e60a2322 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs @@ -0,0 +1,116 @@ +// Port of clients/go/ahp/hosts/hosts_test.go. +// Uses the same in-memory transport pair from ClientTests. +#nullable enable + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class HostsTests +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + // ── Fake server helper (mirrors hosts_test.go / runFakeServer) ──────── + + private static async Task RunFakeServerAsync(MemTransport serverSide, CancellationToken ct) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + if (msg.Request?.Method == "initialize") + { + var result = new InitializeResult { ProtocolVersion = ProtocolVersion.Current }; + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = msg.Request.Id, + Result = JsonDocument.Parse(Ser.Serialize(result)).RootElement, + } + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); } + catch { return; } + } + } + } + catch (OperationCanceledException) { } + } + + // ── Single host handshake ───────────────────────────────────────────── + + [Fact] + public async Task SingleHostHandshake_ConnectedWithProtocolVersion() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var serverTask = Task.Run(() => RunFakeServerAsync(serverSide, cts.Token)); + + var cfg = new HostConfig + { + Id = new HostId("local"), + Label = "Local", + TransportFactory = (hostId, ct) => Task.FromResult(clientSide), + }; + + var (multi, handle) = await MultiHostClient.SingleAsync(cfg, cts.Token); + await using var disposeMulti = multi; + + Assert.Equal(HostStateKind.Connected, handle.State.Kind); + Assert.Equal(ProtocolVersion.Current, handle.ProtocolVersion); + Assert.False(string.IsNullOrEmpty(handle.ClientId), + "ClientID should be auto-generated and non-empty"); + _ = serverTask; // referenced to avoid unused-variable warning + } + + // ── ClientID persisted across Add/Remove/Add ────────────────────────── + + [Fact] + public async Task ClientId_PersistedAcrossRemoveAndReAdd() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var multi = new MultiHostClient(); + await using var disposeMulti = multi; + + // Factory that wires up a fresh fake server each time. + Func> factory = (hostId, ct) => + { + var (c, s) = MemTransport.CreatePair(); + var srvTask = Task.Run(() => RunFakeServerAsync(s, ct)); + _ = srvTask; // fire and forget + return Task.FromResult(c); + }; + + var cfg = new HostConfig + { + Id = new HostId("host-a"), + Label = "A", + TransportFactory = (id, ct) => factory(id, ct), + }; + + var h1 = await multi.AddHostAsync(cfg, cts.Token); + var firstId = h1.ClientId; + + await multi.RemoveHostAsync(new HostId("host-a"), cts.Token); + + var h2 = await multi.AddHostAsync(cfg, cts.Token); + + Assert.Equal(firstId, h2.ClientId); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/JsonCanon.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/JsonCanon.cs new file mode 100644 index 00000000..5cee4563 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/JsonCanon.cs @@ -0,0 +1,72 @@ +#nullable enable + +using System; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace Microsoft.AgentHostProtocol.Tests; + +/// +/// Canonical JSON string for structural comparison: object keys sorted, and +/// null-valued object members dropped (so an omitted optional field +/// equals an explicit null — matching the Go/TS conformance harnesses). +/// +internal static class JsonCanon +{ + public static string Of(JsonElement element) + { + var sb = new StringBuilder(); + Write(element, sb); + return sb.ToString(); + } + + private static void Write(JsonElement e, StringBuilder sb) + { + switch (e.ValueKind) + { + case JsonValueKind.Object: + sb.Append('{'); + var first = true; + foreach (var p in e.EnumerateObject() + .Where(p => p.Value.ValueKind != JsonValueKind.Null) + .OrderBy(p => p.Name, StringComparer.Ordinal)) + { + if (!first) sb.Append(','); + first = false; + sb.Append(JsonSerializer.Serialize(p.Name)).Append(':'); + Write(p.Value, sb); + } + + sb.Append('}'); + break; + case JsonValueKind.Array: + sb.Append('['); + var firstItem = true; + foreach (var item in e.EnumerateArray()) + { + if (!firstItem) sb.Append(','); + firstItem = false; + Write(item, sb); + } + + sb.Append(']'); + break; + case JsonValueKind.String: + sb.Append(JsonSerializer.Serialize(e.GetString())); + break; + case JsonValueKind.Number: + sb.Append(e.GetRawText()); + break; + case JsonValueKind.True: + sb.Append("true"); + break; + case JsonValueKind.False: + sb.Append("false"); + break; + default: + sb.Append("null"); + break; + } + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs new file mode 100644 index 00000000..cccffaf9 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs @@ -0,0 +1,2506 @@ +// Port of the H-group multi-host parity tests (Phase 1 rows). Drives the real +// MultiHostClient over the real MemTransport with a fake server — no mocking of +// the client, the transport, or the JSON engine. Reuses the MemTransport helper +// and the RunFakeServer idiom established by HostsTests.cs / ClientTests.cs. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; // mirror/client tests that build wire payloads +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class MultiHostClientTests +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + // ── Fake server helpers ─────────────────────────────────────────────── + + /// + /// Server loop that answers initialize. Mirrors HostsTests.RunFakeServer. + /// + private static async Task RunFakeServerAsync(MemTransport serverSide, CancellationToken ct) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + if (msg.Request?.Method == "initialize") + { + await RespondInitializeAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { } + } + + /// + /// Server loop that answers initialize then pushes the same + /// action notification on with + /// on a short repeat. Used to prove host-tagged + /// events fan out. The repeat closes the timing gap between the server's + /// post-initialize send and the host pump registering its event stream + /// (the pump only attaches after InitializeAsync returns inside + /// MultiHostClient.OpenHostAsync). DropOldest channels make the extra sends + /// harmless — the consumer reads exactly one. + /// + private static async Task RunFakeServerWithActionAsync( + MemTransport serverSide, + string actionChannel, + long serverSeq, + CancellationToken ct) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + if (msg.Request?.Method == "initialize") + { + await RespondInitializeAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + // Fire-and-forget repeated push so the pump can't miss it. + _ = Task.Run(() => RepeatActionAsync(serverSide, actionChannel, serverSeq, ct)); + } + else if (msg.Request?.Method == "reconnect") + { + // The supervisor's reconnect issues a `reconnect` RPC + // (lastSeenServerSeq) rather than re-initializing. Reply with a + // replay carrying an action at the (advanced) serverSeq on the + // action channel, mirroring a host that resumes from the gap. + var replay = new ReconnectReplayResult + { + Actions = new System.Collections.Generic.List + { + new ActionEnvelope + { + Channel = actionChannel, + ServerSeq = serverSeq, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 7, + }), + }, + }, + Missing = new System.Collections.Generic.List(), + }; + await RespondResultAsync(serverSide, msg.Request.Id, new ReconnectResult(replay), ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { } + } + + /// Pushes the action repeatedly until cancelled or the peer drops. + private static async Task RepeatActionAsync( + MemTransport serverSide, string channel, long serverSeq, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + await SendActionAsync(serverSide, channel, serverSeq, ct).ConfigureAwait(false); + await Task.Delay(25, ct).ConfigureAwait(false); + } + } + catch { /* cancelled or peer gone */ } + } + + private static async Task RespondInitializeAsync( + MemTransport serverSide, ulong id, CancellationToken ct) + { + var result = new InitializeResult { ProtocolVersion = ProtocolVersion.Current }; + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = id, + Result = JsonDocument.Parse(Ser.Serialize(result)).RootElement, + } + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + private static async Task SendActionAsync( + MemTransport serverSide, string channel, long serverSeq, CancellationToken ct) + { + var envelope = new ActionEnvelope + { + Channel = channel, + ServerSeq = serverSeq, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = $"seq-{serverSeq}", + }), + }; + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "action", + Params = JsonDocument.Parse(Ser.Serialize(envelope)).RootElement, + } + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(notif), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + // ── H: two hosts independent ─────────────────────────────────────────── + + [Fact] + public async Task MultiHost_TwoHosts_RegisterAndConnectIndependently() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var m = new MultiHostClient(); + await using var disposeMulti = m; + + HostTransportFactory factory = (hostId, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerAsync(s, ct)); // fire-and-forget fake server + return Task.FromResult(c); + }; + + var hA = await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-a"), + Label = "A", + TransportFactory = (id, ct) => factory(id, ct), + }, cts.Token); + + var hB = await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-b"), + Label = "B", + TransportFactory = (id, ct) => factory(id, ct), + }, cts.Token); + + Assert.Equal(HostStateKind.Connected, hA.State.Kind); + Assert.Equal(HostStateKind.Connected, hB.State.Kind); + Assert.False(string.IsNullOrEmpty(hA.ClientId)); + Assert.False(string.IsNullOrEmpty(hB.ClientId)); + Assert.NotEqual(hA.ClientId, hB.ClientId); // each host mints its own clientId + Assert.Equal(2, m.Hosts().Count); + } + + // ── H: events tagged hostId ──────────────────────────────────────────── + + [Fact] + public async Task MultiHost_Events_CarryHostIdAndResource() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var m = new MultiHostClient(); + await using var disposeMulti = m; + + var subs = m.Subscriptions(); + const string channel = "ahp-session:/s1"; + + // Host-a's server pushes an action after initialize; host-b stays quiet. + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-a"), + TransportFactory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerWithActionAsync(s, channel, 1, ct)); + return Task.FromResult(c); + }, + }, cts.Token); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-b"), + TransportFactory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerAsync(s, ct)); + return Task.FromResult(c); + }, + }, cts.Token); + + // The event read off the top-level subscriptions reader is tagged with + // the originating host and the channel URI it was scoped to. + var ev = await subs.ReadAsync(cts.Token); + Assert.Equal(new HostId("host-a"), ev.HostId); + Assert.Equal(channel, ev.Channel); + var action = Assert.IsType(ev.Event); + Assert.Equal(1, action.Envelope.ServerSeq); + } + + // ── H: reconnect replay ──────────────────────────────────────────────── + + [Fact] + public async Task MultiHost_Reconnect_ReplaysActionsWithAdvancedSeq() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + var m = new MultiHostClient(); + await using var disposeMulti = m; + + var subs = m.Subscriptions(); + const string channel = "ahp-session:/s1"; + + // Per-attempt transport factory. The first connection's server pushes an + // action at serverSeq=1 and then closes (simulating a drop). The fast + // ReconnectPolicy makes the supervisor reconnect; on the SECOND connection + // the supervisor issues a `reconnect` RPC (lastSeenServerSeq) and the + // server replays an action at the ADVANCED serverSeq=2. We assert that a + // post-reconnect event carries the higher serverSeq end-to-end — the real + // reconnect-replay path (OpenHostAsync → ReconnectAsync), mirroring Swift. + var attempt = 0; + HostTransportFactory factory = (hostId, ct) => + { + var n = Interlocked.Increment(ref attempt); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + { + _ = Task.Run(async () => + { + // Respond to initialize, push seq=1 a few times so the pump + // can't miss it, then drop the transport to force a reconnect. + try + { + var frame = await s.ReceiveAsync(ct).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + if (msg.Request?.Method == "initialize") + { + await RespondInitializeAsync(s, msg.Request.Id, ct).ConfigureAwait(false); + for (var i = 0; i < 4 && !ct.IsCancellationRequested; i++) + { + await SendActionAsync(s, channel, 1, ct).ConfigureAwait(false); + await Task.Delay(15, ct).ConfigureAwait(false); + } + } + } + catch { /* ignore */ } + finally + { + await s.CloseAsync().ConfigureAwait(false); // force a drop + } + }); + } + else + { + // Reconnected connection: respond to initialize, push seq=2. + _ = Task.Run(() => RunFakeServerWithActionAsync(s, channel, 2, ct)); + } + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-a"), + TransportFactory = (id, ct) => factory(id, ct), + // Fast reconnect so the test does not wait the 1s default backoff. + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(20), + MaxBackoff = TimeSpan.FromMilliseconds(20), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + + // Drain events until we observe the advanced (post-reconnect) serverSeq. + long maxSeqSeen = 0; + while (maxSeqSeen < 2) + { + var ev = await subs.ReadAsync(cts.Token); + if (ev.Event is SubscriptionEventAction action) + { + maxSeqSeen = Math.Max(maxSeqSeen, action.Envelope.ServerSeq); + Assert.Equal(new HostId("host-a"), ev.HostId); + } + } + + // The post-reconnect action carried the advanced serverSeq. + Assert.Equal(2, maxSeqSeen); + } + + // ══════════════════════════════════════════════════════════════════════ + // Phase 2 (P2-C) — aggregated views, per-host streams, manual reconnect, + // typed host errors. Ported from Swift MultiHostClientTests.swift. Drives + // the REAL MultiHostClient over REAL MemTransport pairs with a fake server + // — NO mocking of the client, transport, or serializer. + // ══════════════════════════════════════════════════════════════════════ + + // ── Extra fake-server helpers (Swift FakeHost parity) ────────────────── + + /// + /// Full fake server: answers initialize (optionally embedding a root + /// snapshot carrying + ), + /// answers listSessions with , and — if + /// is set — pushes a root/sessionAdded + /// notification (scoped to the root channel) shortly after initialize. The + /// notification is repeated until cancelled so the host pump can't miss it. + /// Mirrors Swift's makeFakeHostFactory(state:injectAfterInit:). + /// + private static async Task RunFakeServerFullAsync( + MemTransport serverSide, + IReadOnlyList? sessions = null, + IReadOnlyList? agents = null, + long activeSessions = 0, + SessionSummary? injectAfterInit = null, + CancellationToken ct = default) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + var method = msg.Request?.Method; + if (method == "initialize") + { + await RespondInitializeWithRootAsync(serverSide, msg.Request!.Id, agents, activeSessions, ct).ConfigureAwait(false); + if (injectAfterInit is not null) + _ = Task.Run(() => RepeatSessionAddedAsync(serverSide, injectAfterInit, ct)); + } + else if (method == "listSessions") + { + await RespondListSessionsAsync(serverSide, msg.Request!.Id, sessions ?? Array.Empty(), ct).ConfigureAwait(false); + } + else if (msg.Request is not null) + { + // Acknowledge any other request with an empty object so the + // client's pending entry resolves. + await RespondEmptyAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { } + } + + /// + /// Reconnect-capable fake server: answers initialize + listSessions, + /// then on reconnect replies with a replay result carrying a single + /// rootActiveSessionsChanged action at and the + /// given URIs. Mirrors Swift's + /// makeReconnectResultFactory (replayWithMissingAndLiveAction mode). + /// + private static async Task RunReconnectFakeServerAsync( + MemTransport serverSide, + long replaySeq, + string[] missing, + CancellationToken ct = default) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + var method = msg.Request?.Method; + if (method == "initialize") + { + await RespondInitializeWithRootAsync(serverSide, msg.Request!.Id, null, 1, ct).ConfigureAwait(false); + } + else if (method == "listSessions") + { + await RespondListSessionsAsync(serverSide, msg.Request!.Id, Array.Empty(), ct).ConfigureAwait(false); + } + else if (method == "reconnect") + { + var replay = new ReconnectReplayResult + { + Actions = new System.Collections.Generic.List + { + new ActionEnvelope + { + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = replaySeq, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 7, + }), + }, + }, + Missing = new System.Collections.Generic.List(missing), + }; + await RespondResultAsync(serverSide, msg.Request!.Id, new ReconnectResult(replay), ct).ConfigureAwait(false); + } + else if (msg.Request is not null) + { + await RespondEmptyAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { } + } + + private static async Task RespondInitializeWithRootAsync( + MemTransport serverSide, ulong id, IReadOnlyList? agents, long activeSessions, CancellationToken ct) + { + var result = new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, + ServerSeq = 0, + Snapshots = new System.Collections.Generic.List + { + new Snapshot + { + Resource = ProtocolVersion.RootResourceUri, + FromSeq = 0, + State = new SnapshotState + { + Root = new RootState + { + Agents = agents is not null + ? new System.Collections.Generic.List(agents) + : new System.Collections.Generic.List(), + ActiveSessions = activeSessions, + }, + }, + }, + }, + }; + await RespondResultAsync(serverSide, id, result, ct).ConfigureAwait(false); + } + + private static async Task RespondListSessionsAsync( + MemTransport serverSide, ulong id, IReadOnlyList sessions, CancellationToken ct) + { + var result = new ListSessionsResult { Items = new System.Collections.Generic.List(sessions) }; + await RespondResultAsync(serverSide, id, result, ct).ConfigureAwait(false); + } + + private static async Task RespondEmptyAsync(MemTransport serverSide, ulong id, CancellationToken ct) + { + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = id, + Result = JsonDocument.Parse("{}").RootElement, + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + private static async Task RespondResultAsync(MemTransport serverSide, ulong id, T result, CancellationToken ct) + { + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = id, + Result = JsonDocument.Parse(Ser.Serialize(result)).RootElement, + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + private static async Task RepeatSessionAddedAsync(MemTransport serverSide, SessionSummary summary, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + await SendSessionAddedAsync(serverSide, summary, ct).ConfigureAwait(false); + await Task.Delay(25, ct).ConfigureAwait(false); + } + } + catch { /* cancelled or peer gone */ } + } + + private static async Task SendSessionAddedAsync(MemTransport serverSide, SessionSummary summary, CancellationToken ct) + { + var p = new SessionAddedParams { Channel = ProtocolVersion.RootResourceUri, Summary = summary }; + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "root/sessionAdded", + Params = JsonDocument.Parse(Ser.Serialize(p)).RootElement, + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(notif), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + private static SessionSummary MakeSummary(string resource, string title, long modifiedAt) => new() + { + Resource = resource, + Provider = "copilot", + Title = title, + Status = SessionStatus.Idle, + CreatedAt = 0, + ModifiedAt = modifiedAt, + }; + + private static AgentInfo MakeAgent(string provider = "copilot") => new() + { + Provider = provider, + DisplayName = "Copilot", + Description = "", + Models = new System.Collections.Generic.List(), + }; + + /// Polls until true or the deadline passes. + private static async Task WaitUntilAsync(Func predicate, CancellationToken ct, int timeoutMs = 4000) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (predicate()) return; + await Task.Delay(15, ct).ConfigureAwait(false); + } + throw new TimeoutException("condition not met before deadline"); + } + + /// Polls a host until its state matches . + private static Task WaitForHostStateAsync(MultiHostClient m, HostId id, Func match, CancellationToken ct, int timeoutMs = 6000) => + WaitUntilAsync(() => m.Host(id) is { } h && match(h.State), ct, timeoutMs); + + /// Reads the next item from a reader with a per-read timeout. + private static async Task<(bool Ok, T Value)> ReadWithTimeoutAsync( + System.Threading.Channels.ChannelReader reader, CancellationToken ct, int timeoutMs = 1500) + { + using var to = CancellationTokenSource.CreateLinkedTokenSource(ct); + to.CancelAfter(timeoutMs); + try { var v = await reader.ReadAsync(to.Token).ConfigureAwait(false); return (true, v); } + catch (OperationCanceledException) { return (false, default!); } + catch (System.Threading.Channels.ChannelClosedException) { return (false, default!); } + } + + private static HostTransportFactory FullFactory( + CancellationToken outerCt, + IReadOnlyList? sessions = null, + IReadOnlyList? agents = null, + long activeSessions = 0, + SessionSummary? injectAfterInit = null) => + (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, sessions, agents, activeSessions, injectAfterInit, outerCt)); + return Task.FromResult(c); + }; + + // ── 1. duplicate host id → typed exception ───────────────────────────── + + [Fact] + public async Task MultiHost_DuplicateHostId_ThrowsDuplicateHostException() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("dup"), + Label = "first", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + + var ex = await Assert.ThrowsAsync(() => + m.AddHostAsync(new HostConfig + { + Id = new HostId("dup"), + Label = "second", + TransportFactory = FullFactory(cts.Token), + }, cts.Token)); + Assert.Equal(new HostId("dup"), ex.HostId); + } + + // ── 2. aggregated sessions sorted + host-labeled ─────────────────────── + + [Fact] + public async Task MultiHost_AggregatedSessions_SortedAndHostLabeled() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + var initial = MakeSummary("ahp-session:/s1", "Initial title", modifiedAt: 1_000); + var added = MakeSummary("ahp-session:/s2", "Added later", modifiedAt: 2_000); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("local"), + Label = "Local", + TransportFactory = FullFactory(cts.Token, sessions: new[] { initial }, injectAfterInit: added), + }, cts.Token); + + await WaitForHostStateAsync(m, new HostId("local"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitUntilAsync(() => m.AggregatedSessions().Count == 2, cts.Token); + + var aggregated = m.AggregatedSessions(); + Assert.Equal(2, aggregated.Count); + // Sorted by modifiedAt DESC: "Added later" (2000) before "Initial title" (1000). + Assert.Equal(new[] { "Added later", "Initial title" }, aggregated.ConvertAll(r => r.Summary.Title).ToArray()); + // Every row carries its host id + label. + Assert.All(aggregated, r => Assert.Equal(new HostId("local"), r.HostId)); + Assert.All(aggregated, r => Assert.Equal("Local", r.HostLabel)); + } + + // ── 3. aggregated agents tagged by host ──────────────────────────────── + + [Fact] + public async Task MultiHost_AggregatedAgents_TaggedByHost() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Two hosts, each advertising one agent in its root snapshot. + await m.AddHostAsync(new HostConfig + { + Id = new HostId("a"), + Label = "Host A", + TransportFactory = FullFactory(cts.Token, agents: new[] { MakeAgent("copilot") }), + }, cts.Token); + await m.AddHostAsync(new HostConfig + { + Id = new HostId("b"), + Label = "Host B", + TransportFactory = FullFactory(cts.Token, agents: new[] { MakeAgent("claude") }), + }, cts.Token); + + await WaitForHostStateAsync(m, new HostId("a"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitForHostStateAsync(m, new HostId("b"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitUntilAsync(() => m.AggregatedAgents().Count == 2, cts.Token); + + var agents = m.AggregatedAgents(); + Assert.Equal(2, agents.Count); + // Each agent row carries its originating host id + label. + var byHost = new System.Collections.Generic.Dictionary(); + foreach (var row in agents) byHost[row.HostId.ToString()] = row; + Assert.Equal("Host A", byHost["a"].HostLabel); + Assert.Equal("copilot", byHost["a"].Agent.Provider); + Assert.Equal("Host B", byHost["b"].HostLabel); + Assert.Equal("claude", byHost["b"].Agent.Provider); + } + + // ── 4. host snapshots stream ─────────────────────────────────────────── + + [Fact] + public async Task MultiHost_HostSnapshots_Stream() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Unknown host throws (Swift returns nil; .NET surface throws typed error). + Assert.Throws(() => m.HostSnapshots(new HostId("missing"))); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + + var reader = m.HostSnapshots(new HostId("h")); + // First yield is the initial snapshot for this host. + var (ok0, initial) = await ReadWithTimeoutAsync(reader, cts.Token); + Assert.True(ok0); + Assert.Equal(new HostId("h"), initial.Id); + + // The initial snapshot is already Connected (AddHostAsync returned Connected + // before we subscribed), so count it; also pump in case a connect lands later. + var sawConnected = initial.State.Kind == HostStateKind.Connected; + for (var i = 0; i < 30 && !sawConnected; i++) + { + var (ok, snap) = await ReadWithTimeoutAsync(reader, cts.Token, 500); + if (!ok) continue; + if (snap.State.Kind == HostStateKind.Connected) { sawConnected = true; Assert.Equal(new HostId("h"), snap.Id); } + } + Assert.True(sawConnected, "expected a Connected snapshot on the per-host snapshot stream"); + + // Removing the host finishes the stream so the reader completes. + await m.RemoveHostAsync(new HostId("h"), cts.Token); + var seen = 0; + await foreach (var _u in reader.ReadAllAsync(cts.Token)) { if (++seen > 50) break; } + // The await-foreach exits only when the channel completes; had removal + // NOT finished the stream, the 15s cts would have cancelled the read and + // failed the test. Assert completion explicitly rather than relying on + // "reached here" (matches tests #8 ~L909 and #17 ~L1337 in this file). + Assert.True(reader.Completion.IsCompleted, + "removing the host must complete its per-host snapshot stream"); + Assert.True(seen <= 50, "a finished stream must not keep emitting after removal"); + } + + // ── 5. session summaries stream ──────────────────────────────────────── + + [Fact] + public async Task MultiHost_SessionSummaries_Stream() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Unknown host throws. + Assert.Throws(() => m.SessionSummariesForHost(new HostId("missing"))); + + var initial = MakeSummary("copilot:/s1", "Initial", modifiedAt: 100); + var added = MakeSummary("copilot:/s2", "Added", modifiedAt: 200); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + TransportFactory = FullFactory(cts.Token, sessions: new[] { initial }, injectAfterInit: added), + }, cts.Token); + + var reader = m.SessionSummariesForHost(new HostId("h")); + // Poll the stream until we see BOTH the listSessions-seeded summary + // (copilot:/s1) and the injected sessionAdded (copilot:/s2). + var sawInitial = false; + var sawAdded = false; + for (var i = 0; i < 60 && !(sawInitial && sawAdded); i++) + { + var (ok, list) = await ReadWithTimeoutAsync(reader, cts.Token, 400); + if (!ok) continue; + foreach (var s in list) + { + if (s.Resource == "copilot:/s1") sawInitial = true; + if (s.Resource == "copilot:/s2") sawAdded = true; + } + } + Assert.True(sawInitial, "expected listSessions-seeded summary on the stream"); + Assert.True(sawAdded, "expected injected sessionAdded to update the stream"); + } + + // ── 6. events(host, uri) live ────────────────────────────────────────── + + [Fact] + public async Task MultiHost_HostEvents_Live() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + var initial = MakeSummary("ahp-session:/sess", "init", modifiedAt: 100); + var added = MakeSummary("ahp-session:/added", "post", modifiedAt: 200); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "Host", + TransportFactory = FullFactory(cts.Token, sessions: new[] { initial }, injectAfterInit: added), + }, cts.Token); + + // Attach a per-(host, root-channel) listener; session notifications are + // scoped to the root channel. + var reader = m.EventsForHost(new HostId("h"), ProtocolVersion.RootResourceUri); + + var sawAdded = false; + for (var i = 0; i < 40 && !sawAdded; i++) + { + var (ok, ev) = await ReadWithTimeoutAsync(reader, cts.Token, 400); + if (!ok) continue; + if (ev is SubscriptionEventSessionAdded added2 && added2.Params.Summary.Resource == "ahp-session:/added") + sawAdded = true; + } + Assert.True(sawAdded, "expected the injected sessionAdded notification on the per-channel stream"); + } + + // ── 7. events(host, uri) survives reconnect + sees replay ────────────── + + [Fact] + public async Task MultiHost_HostEvents_SurvivesReconnect() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + HostTransportFactory factory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunReconnectFakeServerAsync(s, replaySeq: 42, missing: new[] { "copilot:/missing" }, ct: cts.Token)); + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "Host", + TransportFactory = factory, + InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("h"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var initialGen = m.Host(new HostId("h"))!.Generation; + + // Attach the per-channel listener AFTER the first connect, BEFORE the + // reconnect, so it is in place when replayed envelopes fan out. + var reader = m.EventsForHost(new HostId("h"), ProtocolVersion.RootResourceUri); + + await m.ReconnectAsync(new HostId("h"), cts.Token); + await WaitUntilAsync(() => + m.Host(new HostId("h")) is { } h && h.Generation > initialGen && h.State.Kind == HostStateKind.Connected, + cts.Token, 8000); + + // The replayed envelope (rootActiveSessionsChanged @ serverSeq=42) must + // reach the per-channel listener since it survives the reconnect. + var sawReplay = false; + for (var i = 0; i < 40 && !sawReplay; i++) + { + var (ok, ev) = await ReadWithTimeoutAsync(reader, cts.Token, 400); + if (!ok) continue; + if (ev is SubscriptionEventAction action && action.Envelope.ServerSeq == 42) + sawReplay = true; + } + Assert.True(sawReplay, "per-channel stream should see replayed envelopes after reconnect"); + } + + // ── 8. events(host, uri) finishes when host is removed ───────────────── + + [Fact] + public async Task MultiHost_HostEvents_FinishesOnUnsubscribe() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("tmp"), + Label = "Temp", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("tmp"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var reader = m.EventsForHost(new HostId("tmp"), "copilot:/x"); + + await m.RemoveHostAsync(new HostId("tmp"), cts.Token); + + // Removing the host finishes the per-(host, uri) stream so the reader + // completes and the await-foreach exits promptly. + var count = 0; + await foreach (var _u in reader.ReadAllAsync(cts.Token)) { if (++count > 10) break; } + // The await-foreach exits only when the channel completes; had removal + // NOT finished the stream, the 15s cts would have cancelled the read and + // failed the test. Assert completion explicitly rather than relying on + // "reached here" (and prove it finished, not that it kept emitting). + Assert.True(reader.Completion.IsCompleted, + "removing the host must complete the per-(host,uri) event stream"); + Assert.True(count <= 10, "a finished stream must not keep emitting after removal"); + } + + // ── 9. reconnect wakes an exhausted (.failed) host ───────────────────── + + [Fact] + public async Task MultiHost_ReconnectHost_WakesExhaustedHost() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(25)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Attempt 1 connects then drops → with a disabled reconnect policy the + // supervisor parks the host in .failed (exhausted/disabled). A manual + // ReconnectAsync bypasses the disabled policy and wakes it; attempt 2 + // connects and stays up. + var attempts = 0; + HostTransportFactory factory = (id, ct) => + { + var n = Interlocked.Increment(ref attempts); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + { + // Answer the handshake then close to force a drop. + _ = Task.Run(() => RunHandshakeThenDropAsync(s, ct)); + } + else + { + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + } + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("ex"), + Label = "Ex", + TransportFactory = factory, + ReconnectPolicy = ReconnectPolicy.Disabled, + }, cts.Token); + + // The first connection drops; disabled policy parks it in .failed. + await WaitForHostStateAsync(m, new HostId("ex"), s => s.Kind == HostStateKind.Failed, cts.Token, 15000); + + // Manual reconnect wakes the exhausted host (bypassing the disabled + // policy) → attempt 2 connects. + await m.ReconnectAsync(new HostId("ex"), cts.Token); + await WaitForHostStateAsync(m, new HostId("ex"), s => s.Kind == HostStateKind.Connected, cts.Token, 15000); + Assert.Equal(HostStateKind.Connected, m.Host(new HostId("ex"))!.State.Kind); + Assert.True(Volatile.Read(ref attempts) >= 2, "manual reconnect should have triggered a second connect attempt"); + } + + /// + /// Answers initialize + listSessions once, then closes the + /// transport to force a drop. Used by reconnect tests that need a host to + /// connect and then drop. + /// + private static async Task RunHandshakeThenDropAsync(MemTransport serverSide, CancellationToken ct) + { + try + { + var sawInit = false; var sawList = false; + while (!ct.IsCancellationRequested && !(sawInit && sawList)) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { break; } + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { break; } + if (msg.Request?.Method == "initialize") + { await RespondInitializeWithRootAsync(serverSide, msg.Request.Id, null, 0, ct).ConfigureAwait(false); sawInit = true; } + else if (msg.Request?.Method == "listSessions") + { await RespondListSessionsAsync(serverSide, msg.Request.Id, Array.Empty(), ct).ConfigureAwait(false); sawList = true; } + else if (msg.Request is not null) + await RespondEmptyAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + } + // Handshake answered → the host reaches Connected (AddHostAsync awaits + // OpenHostAsync). Brief grace so it is Connected + supervised before we + // drop the transport, forcing a clean spontaneous drop. + await Task.Delay(100, ct).ConfigureAwait(false); + } + catch { /* ignore */ } + finally { try { await serverSide.CloseAsync().ConfigureAwait(false); } catch { } } + } + + // ── 10. reconnectAllUnavailable skips connected, wakes others ────────── + + [Fact] + public async Task MultiHost_ReconnectAllUnavailable_SkipsConnected() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Host A: connects and stays connected. + var aAttempts = 0; + HostTransportFactory factoryA = (id, ct) => + { + Interlocked.Increment(ref aAttempts); + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return Task.FromResult(c); + }; + + // Host B: first attempt answers the handshake then DROPS the transport, + // so AddHostAsync returns Connected and B genuinely registers; with a + // disabled reconnect policy the spontaneous drop parks it in .failed + // (NOT removed). The second attempt — driven by ReconnectAllUnavailable — + // returns a working transport and B reconnects. This is the same + // register-then-park-as-.failed shape as test #9 + // (MultiHost_ReconnectHost_WakesExhaustedHost), and mirrors Swift + // testReconnectAllUnavailableSkipsConnectedAndWakesOthers. + var bAttempts = 0; + HostTransportFactory factoryB = (id, ct) => + { + var n = Interlocked.Increment(ref bAttempts); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + _ = Task.Run(() => RunHandshakeThenDropAsync(s, ct)); + else + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig { Id = new HostId("a"), Label = "A", TransportFactory = factoryA }, cts.Token); + await m.AddHostAsync(new HostConfig { Id = new HostId("b"), Label = "B", TransportFactory = factoryB, ReconnectPolicy = ReconnectPolicy.Disabled }, cts.Token); + + // A stays connected; B's first connection drops and the disabled policy + // parks it in .failed (registered, but unavailable). + await WaitForHostStateAsync(m, new HostId("a"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitForHostStateAsync(m, new HostId("b"), s => s.Kind == HostStateKind.Failed, cts.Token, 15000); + Assert.Equal(1, Volatile.Read(ref aAttempts)); + var bAttemptsBefore = Volatile.Read(ref bAttempts); + Assert.Equal(1, bAttemptsBefore); + + // reconnectAllUnavailable must SKIP the connected host A (no error, no + // extra connect attempt) AND WAKE the parked host B. + var errors = await m.ReconnectAllUnavailableAsync(cts.Token); + Assert.Empty(errors); + + // Host B is woken and reconnects. + await WaitForHostStateAsync(m, new HostId("b"), s => s.Kind == HostStateKind.Connected, cts.Token, 15000); + // Host A was skipped: still connected, still exactly one connect attempt. + Assert.Equal(HostStateKind.Connected, m.Host(new HostId("a"))!.State.Kind); + Assert.Equal(1, Volatile.Read(ref aAttempts)); + // Host B re-attempted exactly once (its second connect). + Assert.Equal(2, Volatile.Read(ref bAttempts)); + } + + // ── 11. reconnectAllUnavailable reports per-host errors ──────────────── + + [Fact] + public async Task MultiHost_ReconnectAllUnavailable_ReportsPerHostErrors() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + // With every registered host connected, the unavailable-set is empty, + // so the per-host error map is empty (the success shape of the return). + await m.AddHostAsync(new HostConfig + { + Id = new HostId("x"), + Label = "X", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("x"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var errors = await m.ReconnectAllUnavailableAsync(cts.Token); + // The return is a per-host error MAP (HostId → Exception); connected + // hosts are skipped, so it is empty here. This asserts the per-host-error + // surface shape (a dictionary keyed by HostId) exists and is honored. + Assert.NotNull(errors); + Assert.Empty(errors); + Assert.IsType>(errors); + } + + // ── 12. reconnect aborts a slow transport factory ───────────────────── + + [Fact] + public async Task MultiHost_ReconnectHost_AbortsSlowFactory() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Attempt 1 connects normally. After a forced reconnect, attempt 2's + // factory blocks until cancelled (slow factory). A SECOND manual + // reconnect must abort that hung attempt; attempt 3 then succeeds. + var attempts = 0; + var attempt2Aborted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + HostTransportFactory factory = async (id, ct) => + { + var n = Interlocked.Increment(ref attempts); + if (n == 2) + { + // Slow/hung factory: wait until the per-attempt token is + // cancelled by the next manual reconnect, then surface the + // cancellation (proving the abort path fired). + try { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { attempt2Aborted.TrySetResult(true); throw; } + } + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return c; + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("slow"), + Label = "Slow", + TransportFactory = factory, + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(10), + MaxBackoff = TimeSpan.FromMilliseconds(10), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("slow"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // Force reconnect → attempt #2 runs the slow factory and hangs. + await m.ReconnectAsync(new HostId("slow"), cts.Token); + await WaitUntilAsync(() => Volatile.Read(ref attempts) >= 2, cts.Token, 8000); + + // Second manual reconnect aborts the hung attempt #2… + await m.ReconnectAsync(new HostId("slow"), cts.Token); + var aborted = await Task.WhenAny(attempt2Aborted.Task, Task.Delay(8000, cts.Token)); + Assert.True(attempt2Aborted.Task.IsCompletedSuccessfully, "the slow factory's in-flight attempt should have been aborted"); + + // …and attempt #3 reconnects successfully. + await WaitUntilAsync(() => + m.Host(new HostId("slow")) is { } h && h.State.Kind == HostStateKind.Connected && Volatile.Read(ref attempts) >= 3, + cts.Token, 10000); + Assert.Equal(HostStateKind.Connected, m.Host(new HostId("slow"))!.State.Kind); + } + + // ── 13. unknown host subscribe → typed exception ─────────────────────── + + [Fact] + public async Task MultiHost_UnknownHost_Subscribe_Throws() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var m = new MultiHostClient(); + await using var _mh = m; + + // EventsForHost on an unknown host throws a typed UnknownHostException + // (Swift returns nil; the .NET surface throws, per the test contract). + var ex1 = Assert.Throws(() => m.EventsForHost(new HostId("missing"), "copilot:/anything")); + Assert.Equal(new HostId("missing"), ex1.HostId); + + // SubscribeAsync on an unknown host likewise throws. + var ex2 = await Assert.ThrowsAsync(() => + m.SubscribeAsync(new HostId("missing"), "copilot:/anything", cts.Token)); + Assert.Equal(new HostId("missing"), ex2.HostId); + } + + // ── 14. unknown host dispatch → typed exception ──────────────────────── + + [Fact] + public async Task MultiHost_UnknownHost_Dispatch_Throws() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var m = new MultiHostClient(); + await using var _mh = m; + + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "x", + }); + + var ex = await Assert.ThrowsAsync(() => + m.DispatchAsync(new HostId("missing"), action, "copilot:/s1", cancellationToken: cts.Token)); + Assert.Equal(new HostId("missing"), ex.HostId); + } + + // ── 15. not-connected dispatch → typed exception ─────────────────────── + + [Fact] + public async Task MultiHost_NotConnected_Dispatch_Throws() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + // A host whose initial connect fails is removed by AddHostAsync; rather + // than rely on that, build a host that connects then drops with a + // disabled policy so it parks in .failed (registered, NOT connected). + var connectOnce = 0; + HostTransportFactory factory = (id, ct) => + { + var n = Interlocked.Increment(ref connectOnce); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + { + // Answer initialize + listSessions, then close to force a drop. + _ = Task.Run(async () => + { + try + { + var sawInit = false; var sawList = false; + while (!ct.IsCancellationRequested && !(sawInit && sawList)) + { + var frame = await s.ReceiveAsync(ct).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + if (msg.Request?.Method == "initialize") + { await RespondInitializeWithRootAsync(s, msg.Request.Id, null, 0, ct).ConfigureAwait(false); sawInit = true; } + else if (msg.Request?.Method == "listSessions") + { await RespondListSessionsAsync(s, msg.Request.Id, Array.Empty(), ct).ConfigureAwait(false); sawList = true; } + else if (msg.Request is not null) + await RespondEmptyAsync(s, msg.Request.Id, ct).ConfigureAwait(false); + } + // Handshake answered → the host reaches Connected (AddHostAsync awaits + // OpenHostAsync). Brief grace so it is Connected + supervised before we + // drop the transport; a spontaneous drop on a disabled policy parks the + // host in .failed (registered, not connected) — exactly what this test needs. + await Task.Delay(100, ct).ConfigureAwait(false); + } + catch { /* ignore */ } + finally { await s.CloseAsync().ConfigureAwait(false); } + }); + } + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("nc"), + Label = "NC", + TransportFactory = factory, + ReconnectPolicy = ReconnectPolicy.Disabled, + }, cts.Token); + + // Wait until the host drops and parks in .failed (disabled policy). + await WaitForHostStateAsync(m, new HostId("nc"), s => s.Kind == HostStateKind.Failed, cts.Token, 12000); + + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "x", + }); + + // Host is registered but has no live connection → typed not-connected error. + var ex = await Assert.ThrowsAsync(() => + m.DispatchAsync(new HostId("nc"), action, "copilot:/s1", cancellationToken: cts.Token)); + Assert.Equal(new HostId("nc"), ex.HostId); + } + + // ── 16. handle after remove → HostShutDown ───────────────────────────── + + [Fact] + public async Task MultiHost_HandleAfterRemove_ThrowsHostShutDown() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("temp"), + Label = "Temp", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("temp"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // Acquire a live client handle, then remove the host out from under it. + var handle = m.ClientFor(new HostId("temp")); + Assert.NotNull(handle); + + await m.RemoveHostAsync(new HostId("temp"), cts.Token); + + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "x", + }); + + // The host runtime is gone; the stale handle refuses to operate. + var ex = await Assert.ThrowsAsync(() => + handle!.DispatchAsync(action, "copilot:/s1", cancellationToken: cts.Token)); + Assert.Equal(new HostId("temp"), ex.HostId); + } + + // ══════════════════════════════════════════════════════════════════════ + // Phase 2 (test-only §H gap closure) — additional rows that Swift's + // MultiHostClientTests.swift covers but the .NET suite lacked, plus + // AggregatedSessions tie-break pinning (a mutation sweep found those + // two comparison branches unverified). All drive the REAL MultiHostClient + // over REAL MemTransport pairs with a fake server — NO mocking of the + // client, transport, or serializer; every test asserts a real outcome. + // ══════════════════════════════════════════════════════════════════════ + + // ── 17. removeHost terminates the supervisor (host gone + stream done) ── + // + // Swift's testRemoveHostTerminatesSupervisorAndEmitsEvent asserts both a + // HostEvent.removed(id) on multi.hostEvents() AND supervisor termination. + // The event-emission half is now covered separately by + // MultiHost_RemoveHost_EmitsRemovedEvent (HostEvent gained an IsRemoved + // discriminator). This test pins the supervisor-termination half: after + // RemoveHostAsync the host snapshot is gone (null) and the per-host snapshot + // stream completes (proving the supervisor + its plumbing were torn down, + // not merely orphaned). + [Fact] + public async Task MultiHost_RemoveHost_TerminatesSupervisor() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("temp"), + Label = "Temporary", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("temp"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // A live per-host snapshot stream; removal must finish it. + var snapshots = m.HostSnapshots(new HostId("temp")); + + await m.RemoveHostAsync(new HostId("temp"), cts.Token); + + // The host is no longer registered. + Assert.Null(m.Host(new HostId("temp"))); + + // The per-host stream completes (supervisor + plumbing torn down): the + // await-foreach exits promptly instead of the 15s cts cancelling it. + var seen = 0; + await foreach (var _u in snapshots.ReadAllAsync(cts.Token)) { if (++seen > 50) break; } + Assert.True(snapshots.Completion.IsCompleted, + "removing the host must complete its per-host snapshot stream"); + + // Removing an unknown host throws the typed error. + await Assert.ThrowsAsync(() => + m.RemoveHostAsync(new HostId("temp"), cts.Token)); + } + + // ── 18. the transport factory is invoked once per (re)connect ────────── + // + // Mirrors Swift's testTransportFactoryIsCalledForEachReconnect: the factory + // is a fresh-transport mint, so each connect attempt must call it exactly + // once. After the initial connect the count is 1; a manual reconnect makes + // it 2 (and the host returns to Connected on the new transport). + [Fact] + public async Task MultiHost_TransportFactory_CalledForEachReconnect() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + var calls = 0; + HostTransportFactory factory = (id, ct) => + { + Interlocked.Increment(ref calls); + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("local"), + Label = "Local", + TransportFactory = factory, + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(20), + MaxBackoff = TimeSpan.FromMilliseconds(20), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("local"), s => s.Kind == HostStateKind.Connected, cts.Token); + Assert.Equal(1, Volatile.Read(ref calls)); + + // Force a reconnect → the factory is invoked a second time and the host + // reconnects on the fresh transport. + await m.ReconnectAsync(new HostId("local"), cts.Token); + await WaitUntilAsync(() => + Volatile.Read(ref calls) >= 2 && m.Host(new HostId("local")) is { State.Kind: HostStateKind.Connected }, + cts.Token, 8000); + Assert.Equal(2, Volatile.Read(ref calls)); + } + + // ── 19. event/subscription readers receive nothing after shutdown ────── + // + // Maps Swift's testShutdownTearsDownAllHostsAndStreams stream-finish half + // to the .NET reader surface: after ShutdownAsync, both the connection-event + // reader (Events()) and the subscription fan-in reader (Subscriptions()) + // complete, so a drain reads zero further items and the ReadAllAsync loop + // exits. (Pinning "recv none after transport/host teardown".) + [Fact] + public async Task MultiHost_ClientEvents_RecvNoneAfterShutdown() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("h"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var events = m.Events(); + var subs = m.Subscriptions(); + + await m.ShutdownAsync(cts.Token); + + // Both readers must complete so their drains terminate (no item is + // delivered after teardown). Had shutdown NOT completed them, the cts + // would cancel the ReadAllAsync and fail the test. + var evCount = 0; + await foreach (var _u in events.ReadAllAsync(cts.Token)) { if (++evCount > 100) break; } + var subCount = 0; + await foreach (var _u in subs.ReadAllAsync(cts.Token)) { if (++subCount > 100) break; } + + Assert.True(events.Completion.IsCompleted, "shutdown must complete the connection-event reader"); + Assert.True(subs.Completion.IsCompleted, "shutdown must complete the subscription fan-in reader"); + + // No host snapshots are retrievable after shutdown. + Assert.Null(m.Host(new HostId("h"))); + } + + // ── 20. shutdown is not blocked by a hung transport factory ──────────── + // + // Mirrors the intent behind Swift's parked-attempt teardown: a host whose + // reconnect attempt is stuck inside a transport factory that never returns + // must NOT wedge ShutdownAsync. We drive the host to a hung attempt #2 + // (factory awaits Timeout.Infinite on its per-attempt token), then call + // ShutdownAsync and assert it completes within a bounded window — the + // lifetime cancellation aborts the hung factory. + [Fact] + public async Task MultiHost_Shutdown_NotBlockedByHungTransportFactory() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(25)); + var m = new MultiHostClient(); + + var attempts = 0; + var attempt2Entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + HostTransportFactory factory = async (id, ct) => + { + var n = Interlocked.Increment(ref attempts); + if (n >= 2) + { + // Hung factory: never returns a transport until the per-attempt + // token (cancelled by lifetime teardown) fires. + attempt2Entered.TrySetResult(true); + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return c; + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("hung"), + Label = "Hung", + TransportFactory = factory, + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(10), + MaxBackoff = TimeSpan.FromMilliseconds(10), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("hung"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // Force a reconnect → attempt #2 enters the hung factory and parks. + await m.ReconnectAsync(new HostId("hung"), cts.Token); + await Task.WhenAny(attempt2Entered.Task, Task.Delay(8000, cts.Token)); + Assert.True(attempt2Entered.Task.IsCompletedSuccessfully, "the hung factory's attempt should have started"); + + // ShutdownAsync must complete despite the in-flight hung factory: the + // lifetime cancel aborts the attempt. Bound it well under the cts so a + // wedge fails loudly rather than hanging the whole run. + var shutdown = m.ShutdownAsync(cts.Token); + var winner = await Task.WhenAny(shutdown, Task.Delay(10000, cts.Token)); + Assert.True(ReferenceEquals(winner, shutdown) && shutdown.IsCompletedSuccessfully, + "ShutdownAsync must not be blocked by a hung transport factory"); + } + + // ── 21. explicit clientId wins over store ────────────────────────────── + // + // Pins the clientId-resolution branch in AddHostAsync: an explicit + // HostConfig.ClientId is used verbatim (not the stored value, not a fresh + // mint) AND is persisted to the store. Mirrors the Swift SDK's explicit-id + // precedence (Swift exercises this through its client-id store seams). + [Fact] + public async Task MultiHost_ClientId_ExplicitWins() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var store = new InMemoryClientIdStore(); + // Pre-seed a DIFFERENT id so we can prove explicit wins over stored. + await store.StoreAsync(new HostId("h"), "stored-id", cts.Token); + + var m = new MultiHostClient(store); + await using var _mh = m; + + var handle = await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + ClientId = "explicit-id", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + + Assert.Equal("explicit-id", handle.ClientId); + Assert.Equal("explicit-id", m.Host(new HostId("h"))!.ClientId); + // Explicit id is persisted, overwriting the pre-seeded stored value. + Assert.Equal("explicit-id", await store.LoadAsync(new HostId("h"), cts.Token)); + } + + // ── 22. stored clientId is reused when none is supplied ──────────────── + // + // When HostConfig.ClientId is empty, AddHostAsync loads the persisted id + // from the store and reuses it (the AHP reconnect-stability contract). + [Fact] + public async Task MultiHost_ClientId_StoredReused() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var store = new InMemoryClientIdStore(); + await store.StoreAsync(new HostId("h"), "persisted-id", cts.Token); + + var m = new MultiHostClient(store); + await using var _mh = m; + + var handle = await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + // No ClientId supplied → the stored one is reused. + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + + Assert.Equal("persisted-id", handle.ClientId); + Assert.Equal("persisted-id", m.Host(new HostId("h"))!.ClientId); + Assert.Equal("persisted-id", await store.LoadAsync(new HostId("h"), cts.Token)); + } + + // ── 23. a missing clientId is generated and then persisted ───────────── + // + // With no explicit id and an empty store, AddHostAsync mints a fresh + // non-empty clientId and persists it for future reconnect stability. + [Fact] + public async Task MultiHost_ClientId_MissingGenerates() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var store = new InMemoryClientIdStore(); + Assert.Null(await store.LoadAsync(new HostId("h"), cts.Token)); // empty store + + var m = new MultiHostClient(store); + await using var _mh = m; + + var handle = await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + + Assert.False(string.IsNullOrEmpty(handle.ClientId), "a clientId must be generated"); + // The generated id is the one surfaced on the snapshot AND persisted. + Assert.Equal(handle.ClientId, m.Host(new HostId("h"))!.ClientId); + Assert.Equal(handle.ClientId, await store.LoadAsync(new HostId("h"), cts.Token)); + } + + // ── 24. a cancelled/failed add releases the host-id reservation ──────── + // + // AddHostAsync reserves the id (TryAdd) BEFORE the initial connect, then + // removes it on connect failure (see the catch in AddHostAsync). This pins + // that the reservation is released: a first add whose factory throws fails, + // and a SECOND add of the SAME id then succeeds (no spurious + // DuplicateHostException from a leaked reservation). + [Fact] + public async Task MultiHost_AddHostFailure_ReleasesReservation() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + var attempts = 0; + HostTransportFactory factory = (id, ct) => + { + var n = Interlocked.Increment(ref attempts); + if (n == 1) + throw new AhpTransportException("io", "intentional first-attempt failure"); + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return Task.FromResult(c); + }; + + // First add fails during the initial connect. + await Assert.ThrowsAnyAsync(() => + m.AddHostAsync(new HostConfig + { + Id = new HostId("r"), + Label = "R", + TransportFactory = factory, + ReconnectPolicy = ReconnectPolicy.Disabled, + }, cts.Token)); + + // The reservation was released → the host is NOT registered. + Assert.Null(m.Host(new HostId("r"))); + + // Re-adding the SAME id succeeds (no leaked DuplicateHostException). + var handle = await m.AddHostAsync(new HostConfig + { + Id = new HostId("r"), + Label = "R", + TransportFactory = factory, + }, cts.Token); + Assert.Equal(new HostId("r"), handle.Id); + await WaitForHostStateAsync(m, new HostId("r"), s => s.Kind == HostStateKind.Connected, cts.Token); + Assert.Equal(2, Volatile.Read(ref attempts)); + } + + // ── 25. a client handle invalidates after the host reconnects ────────── + // + // Mirrors Swift's testHostClientHandleInvalidatesAfterReconnect. A handle + // is minted at the current generation; after a reconnect bumps the + // generation the stale handle refuses to operate (Swift surfaces + // .hostReconnected; .NET folds that into HostNotConnectedException — "not + // the connection you held; reacquire"), and a fresh handle works. + [Fact] + public async Task MultiHost_HostClientHandle_InvalidatesAfterReconnect() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("local"), + Label = "Local", + TransportFactory = FullFactory(cts.Token), + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(20), + MaxBackoff = TimeSpan.FromMilliseconds(20), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("local"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var handle = m.ClientFor(new HostId("local")); + Assert.NotNull(handle); + var initialGeneration = handle!.Generation; + handle.CheckAliveOrThrow(); // valid before reconnect + + // FullFactory mints a fresh server per call, so a manual reconnect lands + // a NEW connection at a higher generation. + await m.ReconnectAsync(new HostId("local"), cts.Token); + await WaitUntilAsync(() => + m.Host(new HostId("local")) is { } h && h.Generation > initialGeneration && h.State.Kind == HostStateKind.Connected, + cts.Token, 10000); + + // The stale handle now refuses to operate (generation moved). + Assert.Throws(() => handle.CheckAliveOrThrow()); + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "x", + }); + await Assert.ThrowsAsync(() => + handle.DispatchAsync(action, "copilot:/s1", cancellationToken: cts.Token)); + + // A freshly acquired handle is at the new generation and is valid. + var fresh = m.ClientFor(new HostId("local")); + Assert.NotNull(fresh); + Assert.True(fresh!.Generation > initialGeneration); + fresh.CheckAliveOrThrow(); + } + + // ── 26. a failed handshake shuts down the underlying client ──────────── + // + // Mirrors Swift's testFailedHandshakeShutsDownUnderlyingClient. If + // `initialize` errors after the client's reader/writer tasks have started, + // the supervisor must shut the AhpClient down (which closes the wrapped + // transport) before propagating — otherwise the orphaned client keeps + // holding the transport. We observe this via a tracking transport whose + // Closed flag flips on CloseAsync. + [Fact] + public async Task MultiHost_FailedHandshake_ShutsDownUnderlyingClient() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + var observer = new ClosedObserver(); + HostTransportFactory factory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + // Server returns a JSON-RPC error to `initialize`. + _ = Task.Run(() => RunFailingInitServerAsync(s, ct)); + return Task.FromResult(new TrackingTransport(c, observer)); + }; + + // Disabled policy so the host parks in .failed after one failed handshake + // instead of looping forever. + await Assert.ThrowsAnyAsync(() => + m.AddHostAsync(new HostConfig + { + Id = new HostId("fail"), + Label = "Fail", + TransportFactory = factory, + ReconnectPolicy = ReconnectPolicy.Disabled, + }, cts.Token)); + + // The supervisor shut the AhpClient down on the handshake failure, which + // closed the wrapped transport. + await WaitUntilAsync(() => observer.IsClosed, cts.Token, 8000); + Assert.True(observer.IsClosed, + "AhpClient shutdown on a failed handshake should have closed the transport"); + } + + // ── 27. state during backoff after a drop is Reconnecting ────────────── + // + // Regression mirror of Swift's testStateDuringBackoffAfterDropIsReconnecting: + // while the supervisor sleeps in backoff after a connection dropped, + // snapshots must report Reconnecting (not Connected). We connect, drop the + // transport, and — with a long backoff and a parking second attempt — assert + // the host surfaces Reconnecting during the sleep window. + [Fact] + public async Task MultiHost_StateDuringBackoffAfterDrop_IsReconnecting() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + var attempts = 0; + HostTransportFactory factory = (id, ct) => + { + var n = Interlocked.Increment(ref attempts); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + { + // Answer the handshake, reach Connected, then drop to force the + // post-drop backoff window. + _ = Task.Run(() => RunHandshakeThenDropAsync(s, ct)); + } + else + { + // Subsequent attempts park (never reply) so the runtime stays in + // the Reconnecting/backoff window while we observe. + _ = Task.Run(async () => { try { await s.ReceiveAsync(ct).ConfigureAwait(false); } catch { } }); + } + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("drop"), + Label = "Drop", + TransportFactory = factory, + // Long backoff so there is a generous window to observe Reconnecting + // during the sleep (SuperviseAsync sets Reconnecting BEFORE the sleep). + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromSeconds(5), + MaxBackoff = TimeSpan.FromSeconds(5), + BackoffMultiplier = 1.0, + Jitter = 0.0, + ResetOnSuccess = true, + }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("drop"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // After the drop the supervisor transitions to Reconnecting and sleeps + // the (long) backoff; the state must read Reconnecting during that sleep. + await WaitForHostStateAsync(m, new HostId("drop"), s => s.Kind == HostStateKind.Reconnecting, cts.Token, 8000); + Assert.Equal(HostStateKind.Reconnecting, m.Host(new HostId("drop"))!.State.Kind); + } + + // ── 28. MultiHostClient shutdown is idempotent ───────────────────────── + // + // Mirrors the idempotency tail of Swift's testShutdownTearsDownAllHostsAndStreams: + // a second ShutdownAsync is a safe no-op, and a post-shutdown AddHostAsync + // is rejected with HostShutDownException carrying the would-be id. + [Fact] + public async Task MultiHost_Shutdown_IsIdempotent() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("alpha"), + Label = "Alpha", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("alpha"), s => s.Kind == HostStateKind.Connected, cts.Token); + + await m.ShutdownAsync(cts.Token); + // Second shutdown is a no-op (does not throw, returns promptly). + await m.ShutdownAsync(cts.Token); + + Assert.Null(m.Host(new HostId("alpha"))); + + // A post-shutdown add is rejected with the typed error carrying the id. + var ex = await Assert.ThrowsAsync(() => + m.AddHostAsync(new HostConfig + { + Id = new HostId("gamma"), + Label = "Gamma", + TransportFactory = FullFactory(cts.Token), + }, cts.Token)); + Assert.Equal(new HostId("gamma"), ex.HostId); + } + + // ── 29. repeated reconnect cycles stay healthy (no abort-listener leak) ─ + // + // The .NET reconnect path registers a per-attempt cancellation (BeginAttempt) + // that a later manual reconnect / removal can abort. There is no public + // listener-count surface, so we pin the OBSERVABLE consequence of a leak: + // many reconnect cycles in a row keep the host healthy — each cycle bumps + // the generation monotonically and lands back at Connected, with no error + // accumulation, hang, or stuck state. A leaked abort registration would + // eventually wedge a cycle (stuck non-Connected) or fault the host. + [Fact] + public async Task MultiHost_RepeatedReconnectCycles_StayHealthy() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var m = new MultiHostClient(); + await using var _mh = m; + + HostTransportFactory factory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(s, ct: cts.Token)); + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("loop"), + Label = "Loop", + TransportFactory = factory, + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(10), + MaxBackoff = TimeSpan.FromMilliseconds(10), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("loop"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var lastGen = m.Host(new HostId("loop"))!.Generation; + + // Hammer reconnect repeatedly; each cycle must complete cleanly with a + // strictly higher generation and a Connected end state. + for (var i = 0; i < 8; i++) + { + var prevGen = lastGen; + await m.ReconnectAsync(new HostId("loop"), cts.Token); + await WaitUntilAsync(() => + m.Host(new HostId("loop")) is { } h && h.Generation > prevGen && h.State.Kind == HostStateKind.Connected, + cts.Token, 8000); + var snap = m.Host(new HostId("loop"))!; + Assert.Equal(HostStateKind.Connected, snap.State.Kind); + Assert.True(snap.Generation > prevGen, + $"reconnect cycle {i} should bump the generation ({prevGen} -> {snap.Generation})"); + lastGen = snap.Generation; + } + + // Still healthy after the storm of reconnects. + Assert.Equal(HostStateKind.Connected, m.Host(new HostId("loop"))!.State.Kind); + } + + // ── 30. AggregatedSessions tie-break: host registration order ────────── + // + // Pins the FIRST tie-break branch in AggregatedSessions (MultiHostClient.cs: + // host registration-order comparison): when sessions on DIFFERENT hosts share + // an identical Summary.ModifiedAt, every row from the earlier-registered host + // sorts before every row from the later host. + // + // Falsifiability: AggregatedSessions sorts with List.Sort (an UNSTABLE + // introsort). We give EACH host MANY equal-modifiedAt sessions (well past the + // ~16-element insertion-sort threshold) so the host-order comparison is the + // ONLY thing that can produce a deterministic A-before-B partition — neuter it + // (return 0) and the unstable sort interleaves the two hosts' rows, failing + // the "all of host-a precedes all of host-b" assertion. Empirically verified + // to fail against a neutered tie-break before landing. + [Fact] + public async Task MultiHost_AggregatedSessions_HostRegistrationOrderTieBreak() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Every session shares the SAME modifiedAt → the timestamp comparison is a + // tie for ALL pairs, forcing the secondary (host-order) tie-break across a + // large enough set that an unstable sort would scramble it absent the + // comparison. + const long sharedModifiedAt = 5_000; + const int perHost = 12; // > introsort insertion-sort threshold (16 total each side margin) + var aSessions = new List(); + var bSessions = new List(); + for (var i = 0; i < perHost; i++) + { + // Resource ordering is deliberately INTERLEAVED with host so the final + // tie-break (resource ordinal) can't accidentally reproduce the + // host-partition: host-a uses odd-ish keys, host-b even-ish, mixed. + aSessions.Add(MakeSummary($"ahp-session:/a-{(perHost - i):D2}", $"a{i}", sharedModifiedAt)); + bSessions.Add(MakeSummary($"ahp-session:/b-{i:D2}", $"b{i}", sharedModifiedAt)); + } + + // Register "host-a" BEFORE "host-b". + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-a"), + Label = "A", + TransportFactory = FullFactory(cts.Token, sessions: aSessions), + }, cts.Token); + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-b"), + Label = "B", + TransportFactory = FullFactory(cts.Token, sessions: bSessions), + }, cts.Token); + + await WaitForHostStateAsync(m, new HostId("host-a"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitForHostStateAsync(m, new HostId("host-b"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitUntilAsync(() => m.AggregatedSessions().Count == perHost * 2, cts.Token); + + var aggregated = m.AggregatedSessions(); + Assert.Equal(perHost * 2, aggregated.Count); + + // The host-order tie-break must place EVERY host-a row before EVERY host-b + // row (the two hosts share orderIndex 0 vs 1). Find the boundary: the first + // host-b row, and assert no host-a row appears after it. + var hostIds = aggregated.ConvertAll(r => r.HostId); + var firstB = hostIds.FindIndex(h => h.Equals(new HostId("host-b"))); + Assert.Equal(perHost, firstB); // first perHost rows are all host-a + for (var i = 0; i < perHost; i++) + Assert.Equal(new HostId("host-a"), aggregated[i].HostId); + for (var i = perHost; i < perHost * 2; i++) + Assert.Equal(new HostId("host-b"), aggregated[i].HostId); + } + + // ── 31. AggregatedSessions tie-break: Resource ordinal (within a host) ─ + // + // Pins the FINAL tie-break branch (ordinal on Summary.Resource): sessions that + // tie on BOTH modifiedAt AND host (same host, equal timestamp) are ordered by + // Resource ordinal. The per-host snapshot layer co-enforces this ordering, so + // this is a belt-and-suspenders OUTCOME pin spanning both sort layers — the + // user-visible contract is "equal-timestamp sessions on one host come out in a + // deterministic Resource-ordinal order". + // + // Falsifiability: a single host carries MANY equal-modifiedAt sessions listed + // in REVERSE Resource order; the asserted output is strict ascending Resource + // ordinal. A regression in EITHER sort layer (or a switch to an unstable sort + // with no resource tie-break) breaks the strict-ascending assertion on this + // large set. + [Fact] + public async Task MultiHost_AggregatedSessions_ResourceOrdinalTieBreak() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + // One host, all sessions at the SAME modifiedAt, supplied in REVERSE + // resource order (s-20, s-19, …, s-01) so a working ordinal tie-break must + // re-sort them to ascending (s-01, …, s-20). + const long sharedModifiedAt = 9_000; + const int n = 20; + var reversed = new List(); + for (var i = n; i >= 1; i--) + reversed.Add(MakeSummary($"ahp-session:/s-{i:D2}", $"t{i}", sharedModifiedAt)); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("solo"), + Label = "Solo", + TransportFactory = FullFactory(cts.Token, sessions: reversed), + }, cts.Token); + + await WaitForHostStateAsync(m, new HostId("solo"), s => s.Kind == HostStateKind.Connected, cts.Token); + await WaitUntilAsync(() => m.AggregatedSessions().Count == n, cts.Token); + + var aggregated = m.AggregatedSessions(); + Assert.Equal(n, aggregated.Count); + // Equal modifiedAt + same host → strictly ascending Resource ordinal. + var resources = aggregated.ConvertAll(r => r.Summary.Resource); + var expected = new List(); + for (var i = 1; i <= n; i++) expected.Add($"ahp-session:/s-{i:D2}"); + Assert.Equal(expected, resources); + // Explicitly assert strict ordinal ascent (catches any pair inversion). + for (var i = 1; i < resources.Count; i++) + Assert.True(string.CompareOrdinal(resources[i - 1], resources[i]) < 0, + $"row {i - 1} ({resources[i - 1]}) must sort before row {i} ({resources[i]})"); + } + + // ── 32. events(host, uri): a non-matching (empty) resource sees nothing ─ + // + // §H sub-case (events nil/empty-resource). EventsForHost on a KNOWN host with + // a URI that never matches any delivered channel (here the empty string) + // yields a live reader that simply never fires — session notifications are + // scoped to the root channel, so an empty-URI listener observes none of them, + // while a root-channel listener on the SAME host does. This pins that the + // per-(host,uri) fan-out is URI-scoped (not a firehose). + [Fact] + public async Task MultiHost_HostEvents_EmptyResourceListener_SeesNothing() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + var added = MakeSummary("ahp-session:/added", "post", modifiedAt: 200); + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "Host", + TransportFactory = FullFactory(cts.Token, injectAfterInit: added), + }, cts.Token); + + // Listener on an empty/non-matching resource and a control listener on the + // root channel (where root/sessionAdded is scoped). + var emptyReader = m.EventsForHost(new HostId("h"), ""); + var rootReader = m.EventsForHost(new HostId("h"), ProtocolVersion.RootResourceUri); + + // The root listener DOES see the injected sessionAdded. + var sawOnRoot = false; + for (var i = 0; i < 40 && !sawOnRoot; i++) + { + var (ok, ev) = await ReadWithTimeoutAsync(rootReader, cts.Token, 400); + if (ok && ev is SubscriptionEventSessionAdded sa && sa.Params.Summary.Resource == "ahp-session:/added") + sawOnRoot = true; + } + Assert.True(sawOnRoot, "the root-channel listener should see the injected sessionAdded"); + + // The empty-resource listener saw NOTHING in that same window (URI-scoped + // fan-out, not a firehose). + var (gotEmpty, _empty) = await ReadWithTimeoutAsync(emptyReader, cts.Token, 300); + Assert.False(gotEmpty, "an empty/non-matching resource listener must not receive root-channel events"); + } + + // ── Extra fake-server + transport helpers for the gap tests ──────────── + + /// + /// Server loop that responds to initialize with a JSON-RPC ERROR + /// (not a result), driving the client's handshake to fault. Mirrors Swift's + /// startFailingInitFakeHost. Any other request gets an empty success + /// so a fallback path can resolve. + /// + private static async Task RunFailingInitServerAsync(MemTransport serverSide, CancellationToken ct) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + if (msg.Request is null) continue; + if (msg.Request.Method is "initialize" or "reconnect") + { + var resp = new JsonRpcMessage + { + ErrorResponse = new JsonRpcErrorResponse + { + Id = msg.Request.Id, + Error = new JsonRpcErrorObject { Code = -32000, Message = "init refused for test" }, + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(resp), ct).ConfigureAwait(false); } + catch { return; } + } + else + { + await RespondEmptyAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { } + } + + /// + /// Records whether ran. Mirrors + /// Swift's ClosedObserver actor. + /// + private sealed class ClosedObserver + { + private int _closeCount; + public bool IsClosed => Volatile.Read(ref _closeCount) > 0; + public void MarkClosed() => Interlocked.Increment(ref _closeCount); + } + + /// + /// Thin wrapper that flips an observable closed flag + /// on . Used to prove the supervisor shuts the + /// underlying client down (which closes the transport) on a failed handshake. + /// Mirrors Swift's TrackingTransport. + /// + private sealed class TrackingTransport : ITransport + { + private readonly ITransport _inner; + private readonly ClosedObserver _observer; + + public TrackingTransport(ITransport inner, ClosedObserver observer) + { + _inner = inner; _observer = observer; + } + + public ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) => + _inner.SendAsync(message, cancellationToken); + + public ValueTask ReceiveAsync(CancellationToken cancellationToken = default) => + _inner.ReceiveAsync(cancellationToken); + + public async ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _observer.MarkClosed(); + await _inner.CloseAsync(cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + _observer.MarkClosed(); + await _inner.DisposeAsync().ConfigureAwait(false); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // Production-parity gap closure (features Swift ships + tests that the + // .NET surface previously lacked). All drive the REAL MultiHostClient / + // AhpClient over REAL MemTransport pairs with a fake server — NO mocking + // of the client, transport, or serializer; every test asserts a real + // outcome. + // ══════════════════════════════════════════════════════════════════════ + + /// Thread-safe recorder of the clientSeq values a fake server + /// observed on inbound dispatchAction notifications. Mirrors Swift's + /// DispatchRecorder actor. + private sealed class DispatchRecorder + { + private readonly object _gate = new(); + private readonly List _seqs = new(); + public void Append(long seq) { lock (_gate) { _seqs.Add(seq); } } + public List Seqs() { lock (_gate) { return new List(_seqs); } } + } + + /// + /// Full fake server that ALSO captures the clientSeq of every inbound + /// dispatchAction notification into . Answers + /// initialize + listSessions like ; + /// acknowledges other requests with an empty success. Mirrors Swift's + /// startDispatchRecordingHost. + /// + private static async Task RunDispatchRecordingServerAsync( + MemTransport serverSide, DispatchRecorder recorder, CancellationToken ct) + { + try + { + while (true) + { + TransportMessage frame; + try { frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); } + catch { return; } + + JsonRpcMessage msg; + try { msg = Ser.DecodeMessage(frame); } + catch { return; } + + // Capture the clientSeq carried by dispatchAction notifications — + // the real value the client put on the wire (no mocking). + if (msg.Notification?.Method == "dispatchAction" && msg.Notification.Params is { } p) + { + var dispatched = Ser.Deserialize(p.GetRawText()); + recorder.Append(dispatched.ClientSeq); + continue; + } + + var method = msg.Request?.Method; + if (method == "initialize") + await RespondInitializeWithRootAsync(serverSide, msg.Request!.Id, null, 0, ct).ConfigureAwait(false); + else if (method == "listSessions") + await RespondListSessionsAsync(serverSide, msg.Request!.Id, Array.Empty(), ct).ConfigureAwait(false); + else if (msg.Request is not null) + await RespondEmptyAsync(serverSide, msg.Request.Id, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + } + + // ── Gap 1: HostEvent.removed(id) emitted on RemoveHostAsync ───────────── + // + // Swift's testRemoveHostTerminatesSupervisorAndEmitsEvent asserts a + // HostEvent.removed(id) lands on hostEvents() when a host is removed. The + // .NET HostEvent now carries an IsRemoved discriminator (mirroring Swift's + // `removed` enum case); RemoveHostAsync emits it. Pin that a live Events() + // listener observes a removal event for the right host id. + [Fact] + public async Task MultiHost_RemoveHost_EmitsRemovedEvent() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("temp"), + Label = "Temporary", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("temp"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // Attach the connection-event listener BEFORE removal so the removed + // event isn't missed (Swift subscribes to hostEvents() before remove). + var events = m.Events(); + + await m.RemoveHostAsync(new HostId("temp"), cts.Token); + + // Drain until we see the removed event for the right host id. The host + // is already gone by the time the event fires (removal precedes the + // broadcast), so we assert the event, not the registry. + var sawRemoved = false; + for (var i = 0; i < 40 && !sawRemoved; i++) + { + var (ok, ev) = await ReadWithTimeoutAsync(events, cts.Token, 400); + if (!ok) continue; + if (ev.IsRemoved && ev.HostId.Equals(new HostId("temp"))) + sawRemoved = true; + } + Assert.True(sawRemoved, "expected a HostEvent with IsRemoved=true for host 'temp'"); + + // And the host is no longer registered (the removal really happened). + Assert.Null(m.Host(new HostId("temp"))); + } + + // ── Gap 1b: a state-change event is NOT mistaken for a removal ────────── + // + // Falsifiability guard for the IsRemoved discriminator: ordinary state + // transitions (e.g. the connect that drives a host to Connected) must carry + // IsRemoved=false. Without this, "IsRemoved" could be wired to a constant + // and the test above would still pass. + [Fact] + public async Task MultiHost_StateChangeEvent_IsNotRemoved() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + // Listen before adding so we capture the connecting→connected transitions. + var events = m.Events(); + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("h"), s => s.Kind == HostStateKind.Connected, cts.Token); + + // Drain the buffered state-change events; every one must be a non-removal + // carrying a real state kind, and at least one must report Connected. + var sawConnectedNonRemoval = false; + for (var i = 0; i < 40; i++) + { + var (ok, ev) = await ReadWithTimeoutAsync(events, cts.Token, 300); + if (!ok) break; + Assert.False(ev.IsRemoved, "a state-change event must not be flagged as a removal"); + if (ev.State.Kind == HostStateKind.Connected) sawConnectedNonRemoval = true; + } + Assert.True(sawConnectedNonRemoval, "expected a non-removal Connected state-change event"); + } + + // ── Gap 3: subscribe then unsubscribe drops the URI from the replay set ─ + // + // Mirrors the unsubscribe half of Swift's subscribe/unsubscribe replay-set + // tracking. After SubscribeAsync the URI is tracked for replay + // (Host(id).Subscriptions); after UnsubscribeAsync it is gone. + [Fact] + public async Task MultiHost_Unsubscribe_DropsUriFromReplaySet() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var m = new MultiHostClient(); + await using var _mh = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + Label = "H", + TransportFactory = FullFactory(cts.Token), + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("h"), s => s.Kind == HostStateKind.Connected, cts.Token); + + const string uri = "copilot:/sub-target"; + + // Subscribe → the URI is tracked for replay across reconnects. + await m.SubscribeAsync(new HostId("h"), uri, cts.Token); + Assert.Contains(uri, m.Host(new HostId("h"))!.Subscriptions); + + // Unsubscribe → the URI is dropped from the replay set. + await m.UnsubscribeAsync(new HostId("h"), uri, cts.Token); + Assert.DoesNotContain(uri, m.Host(new HostId("h"))!.Subscriptions); + } + + // ── Gap 3b: unsubscribe on an unknown host → typed exception ──────────── + [Fact] + public async Task MultiHost_UnknownHost_Unsubscribe_Throws() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var m = new MultiHostClient(); + await using var _mh = m; + + var ex = await Assert.ThrowsAsync(() => + m.UnsubscribeAsync(new HostId("missing"), "copilot:/anything", cts.Token)); + Assert.Equal(new HostId("missing"), ex.HostId); + } + + // ── Gap 3c: unsubscribe on a registered-but-disconnected host → typed ─── + // + // The .NET surface (symmetric with SubscribeAsync) surfaces the no-live- + // connection case as HostNotConnectedException. Build a host that connects + // then drops with a disabled policy so it parks in .failed (registered, not + // connected) — the same setup MultiHost_NotConnected_Dispatch_Throws uses. + [Fact] + public async Task MultiHost_NotConnected_Unsubscribe_Throws() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var m = new MultiHostClient(); + await using var _mh = m; + + var connectOnce = 0; + HostTransportFactory factory = (id, ct) => + { + var n = Interlocked.Increment(ref connectOnce); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + { + _ = Task.Run(async () => + { + try + { + var sawInit = false; var sawList = false; + while (!ct.IsCancellationRequested && !(sawInit && sawList)) + { + var frame = await s.ReceiveAsync(ct).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + if (msg.Request?.Method == "initialize") + { await RespondInitializeWithRootAsync(s, msg.Request.Id, null, 0, ct).ConfigureAwait(false); sawInit = true; } + else if (msg.Request?.Method == "listSessions") + { await RespondListSessionsAsync(s, msg.Request.Id, Array.Empty(), ct).ConfigureAwait(false); sawList = true; } + else if (msg.Request is not null) + await RespondEmptyAsync(s, msg.Request.Id, ct).ConfigureAwait(false); + } + await Task.Delay(100, ct).ConfigureAwait(false); + } + catch { /* ignore */ } + finally { await s.CloseAsync().ConfigureAwait(false); } + }); + } + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("nc"), + Label = "NC", + TransportFactory = factory, + ReconnectPolicy = ReconnectPolicy.Disabled, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("nc"), s => s.Kind == HostStateKind.Failed, cts.Token, 12000); + + var ex = await Assert.ThrowsAsync(() => + m.UnsubscribeAsync(new HostId("nc"), "copilot:/s1", cts.Token)); + Assert.Equal(new HostId("nc"), ex.HostId); + } + + // ── Gap 4: explicit clientSeq override is sent verbatim on the wire ───── + // + // Mirrors Swift's testDispatchCanUseExplicitClientSeqThroughMultiHostSurfaces + // (42 via the facade dispatch, 77 via the client handle). A fake server + // records the clientSeq the client actually put on the dispatchAction + // notification; both explicit values must arrive exactly, in order. + [Fact] + public async Task MultiHost_Dispatch_ExplicitClientSeq_SentOnWire() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var recorder = new DispatchRecorder(); + var m = new MultiHostClient(); + await using var _mh = m; + + HostTransportFactory factory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunDispatchRecordingServerAsync(s, recorder, cts.Token)); + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("local"), + Label = "Local", + TransportFactory = factory, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("local"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "From app outbox", + }); + + // 42 via the facade surface. + var first = await m.DispatchAsync(new HostId("local"), action, "copilot:/s1", clientSeq: 42, cancellationToken: cts.Token); + Assert.Equal(42, first.ClientSeq); + + // 77 via the generation-checked client handle surface. + var handle = m.ClientFor(new HostId("local")); + Assert.NotNull(handle); + var second = await handle!.DispatchAsync(action, "copilot:/s1", clientSeq: 77, cancellationToken: cts.Token); + Assert.Equal(77, second.ClientSeq); + + // The server observed exactly the explicit sequences the client put on + // the wire, in dispatch order (no auto-increment substitution). + await WaitUntilAsync(() => + { + var seqs = recorder.Seqs(); + return seqs.Count == 2 && seqs[0] == 42 && seqs[1] == 77; + }, cts.Token, 8000); + } + + // ── Gap 4b: an explicit clientSeq advances the auto-increment counter ─── + // + // After dispatching an explicit clientSeq, a subsequent AUTO-assigned + // dispatch must not reuse a number at or below the explicit one (Swift's + // `if clientSeq >= nextClientSeq { nextClientSeq = clientSeq + 1 }`). Prove + // the next auto seq is explicit+1 = 43. + [Fact] + public async Task MultiHost_Dispatch_ExplicitClientSeq_AdvancesAutoCounter() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var recorder = new DispatchRecorder(); + var m = new MultiHostClient(); + await using var _mh = m; + + HostTransportFactory factory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RunDispatchRecordingServerAsync(s, recorder, cts.Token)); + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("local"), + Label = "Local", + TransportFactory = factory, + }, cts.Token); + await WaitForHostStateAsync(m, new HostId("local"), s => s.Kind == HostStateKind.Connected, cts.Token); + + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "x", + }); + + var handle = m.ClientFor(new HostId("local")); + Assert.NotNull(handle); + + // Explicit 42, then an auto-assigned dispatch (clientSeq omitted). + var first = await handle!.DispatchAsync(action, "copilot:/s1", clientSeq: 42, cancellationToken: cts.Token); + Assert.Equal(42, first.ClientSeq); + var auto = await handle.DispatchAsync(action, "copilot:/s1", cancellationToken: cts.Token); + Assert.Equal(43, auto.ClientSeq); // counter advanced past the explicit value + + await WaitUntilAsync(() => + { + var seqs = recorder.Seqs(); + return seqs.Count == 2 && seqs[0] == 42 && seqs[1] == 43; + }, cts.Token, 8000); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs new file mode 100644 index 00000000..3aa0684b --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs @@ -0,0 +1,191 @@ +// Port of clients/go/ahp/hosts/multi_host_state_mirror_test.go (and the TS +// multi_host_state_mirror tests). Exercises the real MultiHostStateMirror, the +// real root reducer, and the real HostSubscriptionEvent type — no mocking. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; // mirror/client tests that build wire payloads +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class MultiHostStateMirrorTests +{ + // A minimal-but-valid RootState carrying a distinguishing active-session + // count so two hosts' roots are observably different snapshots. + private static RootState Root(long activeSessions) => new() + { + Agents = new List(), + ActiveSessions = activeSessions, + }; + + private static SessionState Session(string title) => new() + { + Summary = new SessionSummary { Title = title }, + Lifecycle = SessionLifecycle.Ready, + }; + + // ── G: roots isolated per host ───────────────────────────────────────── + + [Fact] + public void StateMirror_RootStatesIsolatedPerHost() + { + var m = new MultiHostStateMirror(); + var rootA = Root(1); + var rootB = Root(2); + + m.PutRoot("host-a", rootA); + m.PutRoot("host-b", rootB); + + var (gotA, foundA) = m.Root("host-a"); + var (gotB, foundB) = m.Root("host-b"); + + Assert.True(foundA); + Assert.True(foundB); + // Each host keeps its own distinct snapshot. + Assert.Equal(1, gotA!.ActiveSessions); + Assert.Equal(2, gotB!.ActiveSessions); + Assert.NotSame(gotA, gotB); + } + + // ── G: uri collision no clobber ──────────────────────────────────────── + + [Fact] + public void StateMirror_SessionUriCollision_NoClobber() + { + var m = new MultiHostStateMirror(); + var sA = Session("a-title"); + var sB = Session("b-title"); + + // SAME uri, different host — the (hostId, uri) tuple key keeps them + // separate. This is the .NET equivalent of the collision-safe + // hostedResourceKey used by the TS/Go mirrors. + m.PutSession("host-a", "ahp-session:/s1", sA); + m.PutSession("host-b", "ahp-session:/s1", sB); + + var (gotA, foundA) = m.Session("host-a", "ahp-session:/s1"); + var (gotB, foundB) = m.Session("host-b", "ahp-session:/s1"); + + Assert.True(foundA); + Assert.True(foundB); + Assert.Equal("a-title", gotA!.Summary.Title); + Assert.Equal("b-title", gotB!.Summary.Title); + Assert.NotSame(gotA, gotB); + } + + // ── G: root action targets one ───────────────────────────────────────── + + [Fact] + public void StateMirror_ApplyRootAction_UpdatesOnlyTarget() + { + var m = new MultiHostStateMirror(); + m.PutRoot("host-a", Root(1)); + m.PutRoot("host-b", Root(1)); + + // The .NET mirror has no ApplyRootAction method — that behavior is + // composed from the real root reducer + PutRoot. Take host-a's root, + // apply a RootActiveSessionsChanged action through Reducers.ApplyToRoot, + // then write it back. host-b must be untouched. + var (rootA, _) = m.Root("host-a"); + var action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 42, + }); + var outcome = Reducers.ApplyToRoot(rootA!, action); + m.PutRoot("host-a", rootA!); + + Assert.Equal(ReduceOutcome.Applied, outcome); + + var (gotA, _) = m.Root("host-a"); + var (gotB, _) = m.Root("host-b"); + Assert.Equal(42, gotA!.ActiveSessions); // target host changed + Assert.Equal(1, gotB!.ActiveSessions); // other host unchanged + } + + // ── G: session action targets one ────────────────────────────────────── + // Port of Swift MultiHostStateMirrorTests.testApplySessionActionUpdatesOnlyTargetSession. + // Two hosts advertise the SAME session uri (ahp-session:/s1). A session-scoped + // action applied to host-a's session must NOT touch host-b's identically-named + // session. Like the root-action case above, the .NET mirror has no + // ApplySessionAction method — the behavior is composed from the real session + // reducer (Reducers.ApplyToSession) + PutSession, keyed by (hostId, uri). + [Fact] + public void StateMirror_ApplySessionAction_UpdatesOnlyTargetSession() + { + var m = new MultiHostStateMirror(); + m.PutSession("host-a", "ahp-session:/s1", Session("Old")); + m.PutSession("host-b", "ahp-session:/s1", Session("Old")); + + var (sessA, _) = m.Session("host-a", "ahp-session:/s1"); + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "New on host-a", + }); + var outcome = Reducers.ApplyToSession(sessA!, action); + m.PutSession("host-a", "ahp-session:/s1", sessA!); + + Assert.Equal(ReduceOutcome.Applied, outcome); + + var (gotA, _) = m.Session("host-a", "ahp-session:/s1"); + var (gotB, _) = m.Session("host-b", "ahp-session:/s1"); + Assert.Equal("New on host-a", gotA!.Summary.Title); // target session changed + Assert.Equal("Old", gotB!.Summary.Title); // collision-twin untouched + } + + // ── G: forwards subscription event ───────────────────────────────────── + + [Fact] + public void StateMirror_AppliesHostSubscriptionEvent() + { + // The host-tagged event shape carries hostId + channel + the underlying + // subscription event through to consumers of MultiHostClient.Subscriptions(). + var envelope = new ActionEnvelope + { + Channel = "ahp-session:/s1", + ServerSeq = 7, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "Hello", + }), + }; + SubscriptionEvent inner = new SubscriptionEventAction(envelope); + + var hostEv = new HostSubscriptionEvent(new HostId("host-a"), "ahp-session:/s1", inner); + + Assert.Equal(new HostId("host-a"), hostEv.HostId); + Assert.Equal("ahp-session:/s1", hostEv.Channel); + var action = Assert.IsType(hostEv.Event); + Assert.Equal(7, action.Envelope.ServerSeq); + } + + // ── G: reset host drops one ──────────────────────────────────────────── + + [Fact] + public void StateMirror_ResetHost_DropsOnlyThatHost() + { + var m = new MultiHostStateMirror(); + m.PutRoot("host-a", Root(1)); + m.PutSession("host-a", "ahp-session:/s1", Session("a")); + m.PutRoot("host-b", Root(2)); + + // DropHost is the .NET method name; the parity row calls this "Reset". + m.DropHost("host-a"); + + var (_, rootAFound) = m.Root("host-a"); + var (_, sessAFound) = m.Session("host-a", "ahp-session:/s1"); + var (gotB, rootBFound) = m.Root("host-b"); + + Assert.False(rootAFound); // host-a root gone + Assert.False(sessAFound); // host-a session gone + Assert.True(rootBFound); // host-b survives + Assert.Equal(2, gotB!.ActiveSessions); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs new file mode 100644 index 00000000..2ea9fe0d --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs @@ -0,0 +1,51 @@ +// Port of the Swift ReducersTests "Dispatch Validation" cases. These exercise the +// SHIPPED production predicate `Reducers.IsClientDispatchable(StateAction)` — exactly +// like Swift's tests call its production `isClientDispatchable`. The canonical +// client-dispatchable set lives in production (`Reducers.ClientDispatchableActions`, +// mirroring Swift's `clientDispatchableActions`); there is intentionally no test-local +// copy of it here. +// +// The predicate derives each action's wire `type` by serializing a REAL StateAction +// through the REAL serializer and reading the emitted `type` field — exercising the +// generated union + serializer's [WireValue] mapping, not a hand-typed literal. +#nullable enable + +using System.Text.Json; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class NativeReducerTests +{ + // A: clientDispatchable true — a user-channel action (turnStarted) is dispatchable. + [Fact] + public void ClientDispatchable_TrueForUserChannelAction() + { + var action = new StateAction(new SessionTurnStartedAction + { + Type = ActionType.SessionTurnStarted, + TurnId = "t1", + // Message.Origin is a required (non-nullable) JsonElement — give it a + // valid value ("host", per the interop fixtures) so the action + // serializes. A default(JsonElement) is Undefined and unserializable. + Message = new Message + { + Text = "hi", + Origin = JsonDocument.Parse("\"host\"").RootElement.Clone(), + }, + }); + Assert.True(Reducers.IsClientDispatchable(action)); + } + + // A: clientDispatchable false — a host-only action (session/ready) is NOT dispatchable. + [Fact] + public void ClientDispatchable_FalseForHostOnlyAction() + { + var action = new StateAction(new SessionReadyAction + { + Type = ActionType.SessionReady, + }); + Assert.False(Reducers.IsClientDispatchable(action)); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ReconnectPolicyTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ReconnectPolicyTests.cs new file mode 100644 index 00000000..194e4eca --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ReconnectPolicyTests.cs @@ -0,0 +1,135 @@ +#nullable enable + +using System; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +/// +/// Tests the reconnect backoff calculation, including the opt-in jitter that +/// avoids reconnect storms (the dependency-free equivalent of the .NET +/// resilience libraries' "exponential backoff with jitter"). +/// +public sealed class ReconnectPolicyTests +{ + private static ReconnectPolicy Policy(double jitter = 0) => new() + { + InitialBackoff = TimeSpan.FromSeconds(1), + MaxBackoff = TimeSpan.FromSeconds(30), + BackoffMultiplier = 2.0, + Jitter = jitter, + }; + + [Fact] + public void BackoffIsDeterministicAndExponentialWithoutJitter() + { + var p = Policy(); + Assert.Equal(TimeSpan.FromSeconds(1), p.BackoffFor(1)); + Assert.Equal(TimeSpan.FromSeconds(2), p.BackoffFor(2)); + Assert.Equal(TimeSpan.FromSeconds(4), p.BackoffFor(3)); + Assert.Equal(TimeSpan.FromSeconds(8), p.BackoffFor(4)); + } + + [Fact] + public void BackoffCapsAtMaxBackoff() + { + var p = Policy(); + Assert.Equal(TimeSpan.FromSeconds(30), p.BackoffFor(20)); + } + + [Fact] + public void DisabledPolicyReturnsZero() + { + Assert.True(ReconnectPolicy.Disabled.IsDisabled); + Assert.Equal(TimeSpan.Zero, ReconnectPolicy.Disabled.BackoffFor(1)); + } + + [Fact] + public void JitterStaysWithinTheSymmetricBand() + { + var p = Policy(jitter: 0.5); + // attempt 3 base = 1s * 2 * 2 = 4s; ±50% jitter → [2s, 6s]. + for (var i = 0; i < 1000; i++) + { + var d = p.BackoffFor(3); + Assert.InRange(d, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(6)); + } + } + + [Fact] + public void JitterNeverExceedsMaxBackoff() + { + var p = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromSeconds(30), + MaxBackoff = TimeSpan.FromSeconds(30), + BackoffMultiplier = 2.0, + Jitter = 1.0, + }; + for (var i = 0; i < 1000; i++) + { + Assert.True(p.BackoffFor(1) <= TimeSpan.FromSeconds(30)); + } + } + + // ── Jitter == 0 yields the exact deterministic base delay ───────────── + + [Fact] + public void Jitter_Zero_YieldsBaseDelay() + { + // The complement of JitterStaysWithinTheSymmetricBand: with Jitter == 0 + // there is no randomization, so BackoffFor returns the exact exponential + // base delay — repeatedly, with no spread. attempt 3 base = 1s*2*2 = 4s. + var p = Policy(jitter: 0); + for (var i = 0; i < 100; i++) + { + Assert.Equal(TimeSpan.FromSeconds(4), p.BackoffFor(3)); + } + Assert.Equal(TimeSpan.FromSeconds(1), p.BackoffFor(1)); + Assert.Equal(TimeSpan.FromSeconds(8), p.BackoffFor(4)); + } + + // ── Unbounded policy (MaxAttempts == 0) never exhausts ──────────────── + + [Fact] + public void UnboundedPolicy_NeverExhausts() + { + // MaxAttempts == 0 means unlimited retries. There is no IsExhausted on + // the policy; "never exhausts" is expressed as MaxAttempts == 0 plus a + // backoff that stays a finite value bounded by MaxBackoff even at very + // high attempt numbers. + var p = Policy(); + Assert.Equal(0u, p.MaxAttempts); + + for (uint attempt = 1; attempt <= 1000; attempt++) + { + var d = p.BackoffFor(attempt); + Assert.True(d > TimeSpan.Zero); + Assert.True(d <= p.MaxBackoff); + } + + Assert.Equal(p.MaxBackoff, p.BackoffFor(1000)); + } + + // ── Immediate (zero initial) backoff disables and returns zero ──────── + + [Fact] + public void ImmediateBackoff_IsZero() + { + // A policy whose InitialBackoff is TimeSpan.Zero is treated as disabled, + // so BackoffFor returns TimeSpan.Zero for any attempt — the same + // contract DisabledPolicyReturnsZero asserts for ReconnectPolicy.Disabled. + var p = new ReconnectPolicy + { + InitialBackoff = TimeSpan.Zero, + MaxBackoff = TimeSpan.FromSeconds(30), + BackoffMultiplier = 2.0, + }; + + Assert.True(p.IsDisabled); + Assert.Equal(TimeSpan.Zero, p.BackoffFor(1)); + Assert.Equal(TimeSpan.Zero, p.BackoffFor(5)); + Assert.Equal(TimeSpan.Zero, p.BackoffFor(100)); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs new file mode 100644 index 00000000..7eb6eaf9 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs @@ -0,0 +1,268 @@ +// Phase-1 parity tests for matrix group E (transport) — in-memory transport. +// Exercises the REAL in-memory ITransport pair (MemTransport, defined in +// ClientTests.cs) over the REAL SystemTextJsonAhpSerializer. No mocking of +// ITransport or the JSON engine — the transport pair is the production helper +// the client tests use, and frames flow through real channels. +#nullable enable + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class TransportTests +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + // ── E: in-mem both directions ───────────────────────────────────────── + // A frame sent on A arrives on B, and a frame sent on B arrives on A. + [Fact] + public async Task InMemoryTransport_DeliversBothDirections() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // A -> B + await a.SendAsync(TransportMessage.FromText("a-to-b"), cts.Token); + var onB = await b.ReceiveAsync(cts.Token); + Assert.Equal(TransportFrame.Text, onB.Frame); + Assert.Equal("a-to-b", onB.Text); + + // B -> A (a *different* payload, to prove the channels aren't crossed) + await b.SendAsync(TransportMessage.FromText("b-to-a"), cts.Token); + var onA = await a.ReceiveAsync(cts.Token); + Assert.Equal(TransportFrame.Text, onA.Frame); + Assert.Equal("b-to-a", onA.Text); + + await a.CloseAsync(cts.Token); + } + + // ── E: close ends recv ──────────────────────────────────────────────── + // Closing either end unblocks a pending/subsequent ReceiveAsync on BOTH + // ends with the closed signal (MemTransport throws AhpTransportException + // "closed" — see ClientTests.cs MemTransport.ReceiveAsync). + [Fact] + public async Task InMemoryTransport_Close_EndsBothRecv() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Start a receive on each end BEFORE closing, so we prove a *pending* + // receive unblocks (not just a post-close one). + var recvA = a.ReceiveAsync(cts.Token).AsTask(); + var recvB = b.ReceiveAsync(cts.Token).AsTask(); + + // Give both receives a moment to actually park on the channel. + await Task.Delay(50, cts.Token); + + await a.CloseAsync(cts.Token); + + // Both pending receives end with the closed signal. + var exA = await Assert.ThrowsAsync(() => recvA); + Assert.Contains("closed", exA.Message, StringComparison.OrdinalIgnoreCase); + var exB = await Assert.ThrowsAsync(() => recvB); + Assert.Contains("closed", exB.Message, StringComparison.OrdinalIgnoreCase); + + // A subsequent receive on either end also fails fast. + await Assert.ThrowsAsync( + async () => await b.ReceiveAsync(cts.Token)); + } + + // ── E: send after close throws ──────────────────────────────────────── + [Fact] + public async Task InMemoryTransport_SendAfterClose_Throws() + { + var (a, _) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await a.CloseAsync(cts.Token); + + var ex = await Assert.ThrowsAsync( + async () => await a.SendAsync(TransportMessage.FromText("nope"), cts.Token)); + Assert.Contains("closed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // ── E: TransportMessage round-trip ──────────────────────────────────── + // A JSON-RPC notification packed into a TransportMessage.FromText survives + // the transport intact and decodes back to a notification with the same + // method via the real serializer. + [Fact] + public async Task TransportMessage_RoundTrip_Notification() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // A JSON-RPC notification: has "method", no "id". + const string json = "{\"jsonrpc\":\"2.0\",\"method\":\"action\",\"params\":{}}"; + await a.SendAsync(TransportMessage.FromText(json), cts.Token); + + var received = await b.ReceiveAsync(cts.Token); + Assert.Equal(TransportFrame.Text, received.Frame); + Assert.Equal(json, received.Text); + + var decoded = Ser.DecodeMessage(received); + Assert.NotNull(decoded.Notification); + Assert.Null(decoded.Request); + Assert.Equal("action", decoded.Notification!.Method); + + await a.CloseAsync(cts.Token); + } + + // ── E: TransportMessage round-trip — success response ────────────────── + // Port of Swift InMemoryTransportTests.testTransportMessageRoundTripPreservesSuccessResponse. + // Encode a JSON-RPC success response via the REAL serializer, ship it over + // the REAL MemTransport, decode it back, and assert the variant + id + + // result survive. Uses EncodeMessage/DecodeMessage (the .NET analogue of + // Swift's TransportMessage.encode(...).intoParsed()). + [Fact] + public async Task TransportMessage_RoundTrip_SuccessResponse() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var original = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = 42, + Result = JsonDocument.Parse("{\"ok\":true}").RootElement, + }, + }; + + await a.SendAsync(Ser.EncodeMessage(original), cts.Token); + var received = await b.ReceiveAsync(cts.Token); + Assert.Equal(TransportFrame.Text, received.Frame); + + var decoded = Ser.DecodeMessage(received); + Assert.NotNull(decoded.SuccessResponse); + Assert.Null(decoded.Request); + Assert.Null(decoded.ErrorResponse); + Assert.Null(decoded.Notification); + Assert.Equal(42UL, decoded.SuccessResponse!.Id); + Assert.True(decoded.SuccessResponse.Result.GetProperty("ok").GetBoolean()); + + await a.CloseAsync(cts.Token); + } + + // ── E: TransportMessage round-trip — error response ──────────────────── + // Port of Swift InMemoryTransportTests.testTransportMessageRoundTripPreservesErrorResponse. + [Fact] + public async Task TransportMessage_RoundTrip_ErrorResponse() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var original = new JsonRpcMessage + { + ErrorResponse = new JsonRpcErrorResponse + { + Id = 7, + Error = new JsonRpcErrorObject { Code = -32000, Message = "boom" }, + }, + }; + + await a.SendAsync(Ser.EncodeMessage(original), cts.Token); + var received = await b.ReceiveAsync(cts.Token); + + var decoded = Ser.DecodeMessage(received); + Assert.NotNull(decoded.ErrorResponse); + Assert.Null(decoded.Request); + Assert.Null(decoded.SuccessResponse); + Assert.Null(decoded.Notification); + Assert.Equal(7UL, decoded.ErrorResponse!.Id); + Assert.Equal(-32000, decoded.ErrorResponse.Error.Code); + Assert.Equal("boom", decoded.ErrorResponse.Error.Message); + + await a.CloseAsync(cts.Token); + } + + // ── E: TransportMessage round-trip — request ─────────────────────────── + // Port of Swift InMemoryTransportTests.testTransportMessageRoundTripPreservesRequest. + // A request is the (id + method) shape; the shape-probing converter must + // decode it as a Request (not a Notification, which lacks an id). + [Fact] + public async Task TransportMessage_RoundTrip_Request() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var original = new JsonRpcMessage + { + Request = new JsonRpcRequest + { + Id = 1, + Method = "subscribe", + Params = JsonDocument.Parse("{\"channel\":\"ahp-root://\"}").RootElement, + }, + }; + + await a.SendAsync(Ser.EncodeMessage(original), cts.Token); + var received = await b.ReceiveAsync(cts.Token); + + var decoded = Ser.DecodeMessage(received); + Assert.NotNull(decoded.Request); + Assert.Null(decoded.Notification); + Assert.Null(decoded.SuccessResponse); + Assert.Null(decoded.ErrorResponse); + Assert.Equal(1UL, decoded.Request!.Id); + Assert.Equal("subscribe", decoded.Request.Method); + Assert.Equal("ahp-root://", decoded.Request.Params!.Value.GetProperty("channel").GetString()); + + await a.CloseAsync(cts.Token); + } + + // ── E: subscription-buffer clamp — non-positive normalised ───────────── + // Parity row "subscription-buffer clamps (>=1, neg->1, positive)". The .NET + // clamp lives in AhpClient.Connect (AhpClient.cs ~line 233): a non-positive + // SubscriptionBufferCapacity is normalised to the default 256 (NOT to 1 as + // Swift's AHPClientConfig does — see featureGaps note). Exercise the REAL + // clamp by connecting a REAL AhpClient over a REAL MemTransport and reading + // the mutated config back. Theory covers the 0 and negative cases. + [Theory] + [InlineData(0)] + [InlineData(-42)] + public async Task ClientConfig_SubscriptionBuffer_NonPositiveClampsToDefault(int requested) + { + var (clientSide, _) = MemTransport.CreatePair(); + var cfg = new ClientConfig { SubscriptionBufferCapacity = requested }; + + await using var client = AhpClient.Connect(clientSide, cfg); + + // Connect normalised the non-positive request up to the 256 default. + Assert.Equal(256, cfg.SubscriptionBufferCapacity); + } + + // ── E: subscription-buffer clamp — positive preserved ────────────────── + // The complement: a positive capacity passes through Connect untouched. + [Fact] + public async Task ClientConfig_SubscriptionBuffer_PositivePreserved() + { + var (clientSide, _) = MemTransport.CreatePair(); + var cfg = new ClientConfig { SubscriptionBufferCapacity = 64 }; + + await using var client = AhpClient.Connect(clientSide, cfg); + + Assert.Equal(64, cfg.SubscriptionBufferCapacity); + } + + // ── E: defaults are reasonable ───────────────────────────────────────── + // Port of Swift InMemoryTransportTests.AHPClientConfigTests.testDefaultsAreReasonable. + // Asserts the REAL .NET ClientConfig.Default shape. The .NET buffer default + // matches Swift (256) and the request-timeout default matches Swift (30s); + // keep-alive defaults to Disabled, the .NET analogue of Swift's + // KeepAlive == .disabled. + [Fact] + public void ClientConfig_DefaultsAreReasonable() + { + var config = ClientConfig.Default; + + Assert.Equal(256, config.SubscriptionBufferCapacity); + Assert.Equal(TimeSpan.FromSeconds(30), config.DefaultRequestTimeout); + Assert.False(config.KeepAlive.IsEnabled); + Assert.Same(KeepAlivePolicy.Disabled, config.KeepAlive); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs new file mode 100644 index 00000000..4137766d --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -0,0 +1,610 @@ +// Data-driven loader for the shared wire round-trip corpus under +// types/test-cases/round-trips/*.json. Decodes each fixture with the REAL +// System.Text.Json serializer (SystemTextJsonAhpSerializer.Default) and the +// REAL generated wire types, then checks the fixture's assertions against the +// decoded value and the value re-encoded by the same real serializer. +// +// The corpus is language-agnostic (see types/test-cases/round-trips/README.md); +// the same fixtures are intended to drive the Swift / TS / Go / Rust / Kotlin +// clients. This file is the .NET adapter: a logical `type` string → real decode +// dispatch plus a JSON-path / variant / bitset assertion engine. +// +// Two entry points exercise the same corpus: +// * RunFixtureByName(name) — called by the named [Fact] wrappers in +// TypesRoundTripTests.cs so the cross-language master-matrix method names +// (and the parity-manifest rows) survive the move to data-driven fixtures. +// * the [Theory] CorpusFixture below — iterates EVERY fixture in the dir, so +// adding a fixture file is automatically run even before a named wrapper +// exists, and a stray/garbled fixture fails loudly. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class TypesRoundTripFixtures +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + // ── Public entry points ─────────────────────────────────────────────── + + /// + /// Runs one fixture identified by its file's leading number-or-name. Used by + /// the thin named [Fact] wrappers so the master-matrix / parity-manifest + /// method names are preserved while the behavior lives in shared fixtures. + /// Internal (not public) so xUnit does not mistake it for a parameterless + /// test method (xUnit1013); the named wrappers live in the same assembly. + /// + internal static void RunFixtureByName(string fixturePrefix) + { + string path = ResolveFixturePath(fixturePrefix); + VerifyFixture(path); + } + + public static IEnumerable AllFixtures() + { + foreach (string path in EnumerateFixtureFiles()) + { + yield return new object[] { Path.GetFileName(path), path }; + } + } + + /// + /// Iterates the whole corpus. This is additive to the named wrappers (it + /// re-decodes the same fixtures through the dir-walk entry shape that the + /// other-language loaders use), and it is the loud guard that every fixture + /// file on disk is real, parseable, and asserts something. + /// + [Theory] + [MemberData(nameof(AllFixtures))] + public void CorpusFixture(string name, string path) + { + _ = name; + VerifyFixture(path); + } + + // ── Verifier ────────────────────────────────────────────────────────── + + private static void VerifyFixture(string path) + { + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement root = doc.RootElement; + string type = root.GetProperty("type").GetString() + ?? throw new Xunit.Sdk.XunitException($"{Path.GetFileName(path)}: missing `type`"); + + // ProtocolVersion fixtures assert constants, not wire decode. + if (type == "ProtocolVersion") + { + VerifyProtocolConstant(path, root); + return; + } + + // Build the exact input bytes: `wireRaw` (verbatim string) wins; else the + // compact form of the `wire` object. + string inputJson = ReadInputJson(path, root); + + var (decoded, reencoded) = DecodeAndReencode(type, inputJson); + + bool assertedSomething = false; + + if (root.TryGetProperty("expect", out JsonElement expect)) + { + using JsonDocument re = JsonDocument.Parse(reencoded); + foreach (JsonProperty p in expect.EnumerateObject()) + { + JsonElement actual = ResolvePath(re.RootElement, p.Name, path); + AssertJsonEquals(p.Value, actual, $"{Path.GetFileName(path)}: expect[\"{p.Name}\"]"); + assertedSomething = true; + } + } + + if (root.TryGetProperty("expectVariant", out JsonElement variants)) + { + VerifyVariant(path, decoded, variants); + assertedSomething = true; + } + + if (root.TryGetProperty("expectJsonRpcVariant", out JsonElement jrpcVariant)) + { + VerifyJsonRpcVariant(path, decoded, jrpcVariant.GetString()!); + assertedSomething = true; + } + + if (root.TryGetProperty("expectBitset", out JsonElement bitset)) + { + VerifyBitset(path, decoded, reencoded, bitset); + assertedSomething = true; + } + + if (root.TryGetProperty("expectNumberAbove", out JsonElement above)) + { + using JsonDocument re = JsonDocument.Parse(reencoded); + foreach (JsonProperty p in above.EnumerateObject()) + { + JsonElement actual = ResolvePath(re.RootElement, p.Name, path); + long bound = p.Value.GetInt64(); + long got = actual.GetInt64(); + Assert.True( + got > bound, + $"{Path.GetFileName(path)}: expectNumberAbove[\"{p.Name}\"] — {got} is not > {bound}"); + assertedSomething = true; + } + } + + if (root.TryGetProperty("expectReencodedAbsent", out JsonElement absent)) + { + using JsonDocument re = JsonDocument.Parse(reencoded); + foreach (JsonElement keyEl in absent.EnumerateArray()) + { + string key = keyEl.GetString()!; + Assert.False( + re.RootElement.TryGetProperty(key, out _), + $"{Path.GetFileName(path)}: re-encoded JSON must NOT contain key \"{key}\" but it does. Re-encoded: {reencoded}"); + assertedSomething = true; + } + } + + if (root.TryGetProperty("reencodes", out JsonElement reencodesEl) && reencodesEl.GetBoolean()) + { + Assert.True( + reencoded == inputJson, + $"{Path.GetFileName(path)}: re-encode is not byte-exact.\n input: {inputJson}\n re-encoded: {reencoded}"); + assertedSomething = true; + } + + if (root.TryGetProperty("roundTripStable", out JsonElement stableEl) && stableEl.GetBoolean()) + { + // Decode the re-encoded JSON a second time and re-assert `expect`. + var (_, reencoded2) = DecodeAndReencode(type, reencoded); + if (root.TryGetProperty("expect", out JsonElement expect2)) + { + using JsonDocument re2 = JsonDocument.Parse(reencoded2); + foreach (JsonProperty p in expect2.EnumerateObject()) + { + JsonElement actual = ResolvePath(re2.RootElement, p.Name, path); + AssertJsonEquals(p.Value, actual, + $"{Path.GetFileName(path)}: roundTripStable expect[\"{p.Name}\"] (2nd decode)"); + } + } + else + { + // No `expect` to recheck — at minimum the second re-encode must + // equal the first (stable fixed point). + Assert.True( + reencoded2 == reencoded, + $"{Path.GetFileName(path)}: round-trip is not a fixed point.\n 1st: {reencoded}\n 2nd: {reencoded2}"); + } + assertedSomething = true; + } + + Assert.True( + assertedSomething, + $"{Path.GetFileName(path)}: fixture made no assertions (no expect/expectVariant/expectJsonRpcVariant/expectBitset/expectNumberAbove/expectReencodedAbsent/reencodes/roundTripStable). A fixture that checks nothing is coverage theater."); + } + + /// + /// Neutral JSON-RPC variant check. The fixture names the logical variant + /// ("request"/"notification"/"success"/"error"); each language maps it to its + /// own JsonRpcMessage accessor (here, the C# property). Asserts that variant is + /// present and the other three absent — verifying the decoder's wire-shape + /// dispatch without baking .NET property names into the shared corpus. + /// + private static void VerifyJsonRpcVariant(string path, object decoded, string kind) + { + string fname = Path.GetFileName(path); + string? present = kind switch + { + "request" => "Request", + "notification" => "Notification", + "success" => "SuccessResponse", + "error" => "ErrorResponse", + _ => null, + }; + Assert.True(present is not null, + $"{fname}: expectJsonRpcVariant \"{kind}\" is not one of request/notification/success/error"); + foreach (string prop in new[] { "Request", "Notification", "SuccessResponse", "ErrorResponse" }) + { + object? val = decoded.GetType().GetProperty(prop)?.GetValue(decoded); + bool shouldBePresent = prop == present; + Assert.True( + (val is not null) == shouldBePresent, + $"{fname}: expectJsonRpcVariant \"{kind}\" — {prop} is {(val is null ? "absent" : "present")}, expected {(shouldBePresent ? "present" : "absent")}"); + } + } + + // ── Real decode dispatch ────────────────────────────────────────────── + + /// + /// Decodes into the real generated type named by + /// using the real serializer, then re-encodes it with + /// the same serializer. Returns both so assertions can inspect the decoded + /// object (variant identity, flag bits) and the re-encoded wire (field paths, + /// byte-exactness). Adding a wire type to the corpus is a deliberate edit + /// here — the corpus never decodes arbitrary types reflectively. + /// + private static (object decoded, string reencoded) DecodeAndReencode(string type, string inputJson) + { + switch (type) + { + case "ActionEnvelope": + return Wrap(Ser.Deserialize(inputJson)); + case "StateAction": + return Wrap(Ser.Deserialize(inputJson)); + case "Customization": + return Wrap(Ser.Deserialize(inputJson)); + case "SessionStatus": + return Wrap(Ser.Deserialize(inputJson)); + case "StringOrMarkdown": + return Wrap(Ser.Deserialize(inputJson)); + case "JsonRpcMessage": + return Wrap(Ser.Deserialize(inputJson)); + case "ChangesetOperationTarget": + return Wrap(Ser.Deserialize(inputJson)); + case "SessionInputQuestion": + return Wrap(Ser.Deserialize(inputJson)); + case "SessionSummary": + return Wrap(Ser.Deserialize(inputJson)); + case "SessionAddedParams": + return Wrap(Ser.Deserialize(inputJson)); + case "PartialSessionSummary": + return Wrap(Ser.Deserialize(inputJson)); + default: + throw new Xunit.Sdk.XunitException( + $"round-trip fixture: unknown wire type \"{type}\". " + + "Add a decode entry to TypesRoundTripFixtures.DecodeAndReencode."); + } + + (object, string) Wrap(T value) => (value!, Ser.Serialize(value)); + } + + // ── Assertion helpers ───────────────────────────────────────────────── + + private static void VerifyVariant(string path, object decoded, JsonElement variants) + { + foreach (JsonProperty p in variants.EnumerateObject()) + { + string accessor = p.Name; + string want = p.Value.GetString()!; + + if (accessor.Length == 0) + { + // Whole-decoded-value union identity: the active .Value's runtime + // type (e.g. StateAction / Customization decoded directly). + object? value = RequireUnionValue(decoded, path); + AssertVariantType(path, "", value, want); + continue; + } + + // Named accessor (case-insensitive, so a wire name like "action" + // resolves the C# `Action` property). + object? member = GetMember(decoded, accessor, path); + + if (want is "present" or "absent") + { + // Plain-union container with one nullable property per variant + // (e.g. JsonRpcMessage.Request / .Notification). + bool present = member is not null; + bool wantPresent = want == "present"; + Assert.True( + present == wantPresent, + $"{Path.GetFileName(path)}: expectVariant[\"{accessor}\"] — {(present ? "present" : "absent")}, expected {want}"); + } + else + { + // Property is itself an AhpUnion; assert its active .Value's + // concrete type (e.g. ActionEnvelope.Action.Value). + Assert.True( + member is AhpUnion, + $"{Path.GetFileName(path)}: expectVariant[\"{accessor}\"] expected a union value (type \"{want}\"), but property is {(member is null ? "null" : member.GetType().Name)}"); + object? inner = ((AhpUnion)member!).Value; + AssertVariantType(path, accessor, inner, want); + } + } + } + + private static void AssertVariantType(string path, string accessor, object? value, string want) + { + string actualType = value is null ? "null" : value.GetType().Name; + string ctx = accessor.Length == 0 ? "expectVariant[\"\"]" : $"expectVariant[\"{accessor}\"]"; + Assert.True( + actualType == want, + $"{Path.GetFileName(path)}: {ctx} — active .Value is {actualType}, expected {want}"); + } + + private static void VerifyBitset(string path, object decoded, string reencoded, JsonElement bitset) + { + Assert.True( + decoded is SessionStatus, + $"{Path.GetFileName(path)}: expectBitset requires a SessionStatus, got {decoded.GetType().Name}"); + var status = (SessionStatus)decoded; + + if (bitset.TryGetProperty("has", out JsonElement has)) + { + foreach (JsonElement nameEl in has.EnumerateArray()) + { + SessionStatus flag = ParseStatusFlag(nameEl.GetString()!, path); + Assert.True( + status.HasFlag(flag), + $"{Path.GetFileName(path)}: SessionStatus must have flag {nameEl.GetString()} but does not (value {(uint)status})"); + } + } + + if (bitset.TryGetProperty("lacks", out JsonElement lacks)) + { + foreach (JsonElement nameEl in lacks.EnumerateArray()) + { + SessionStatus flag = ParseStatusFlag(nameEl.GetString()!, path); + Assert.False( + status.HasFlag(flag), + $"{Path.GetFileName(path)}: SessionStatus must NOT have flag {nameEl.GetString()} but does (value {(uint)status})"); + } + } + + if (bitset.TryGetProperty("numeric", out JsonElement numericEl)) + { + ulong want = numericEl.GetUInt64(); + Assert.Equal(want, (ulong)(uint)status); + + // The re-encoded wire form must also be the same numeric value. + using JsonDocument re = JsonDocument.Parse(reencoded); + Assert.True( + re.RootElement.ValueKind == JsonValueKind.Number, + $"{Path.GetFileName(path)}: SessionStatus must re-encode as a JSON number, got {re.RootElement.ValueKind}"); + Assert.Equal(want, re.RootElement.GetUInt64()); + } + } + + private static void VerifyProtocolConstant(string path, JsonElement root) + { + JsonElement c = root.GetProperty("expectConstant"); + bool asserted = false; + + if (c.TryGetProperty("current", out JsonElement cur)) + { + Assert.Equal("non-empty", cur.GetString()); + Assert.False( + string.IsNullOrWhiteSpace(ProtocolVersion.Current), + $"{Path.GetFileName(path)}: ProtocolVersion.Current must be non-empty"); + asserted = true; + } + + if (c.TryGetProperty("supported", out JsonElement sup)) + { + Assert.Equal("non-empty-list", sup.GetString()); + Assert.NotEmpty(ProtocolVersion.Supported); + asserted = true; + } + + if (c.TryGetProperty("firstSupportedEqualsCurrent", out JsonElement first) && first.GetBoolean()) + { + Assert.NotEmpty(ProtocolVersion.Supported); + Assert.Equal(ProtocolVersion.Current, ProtocolVersion.Supported[0]); + asserted = true; + } + + Assert.True(asserted, $"{Path.GetFileName(path)}: ProtocolVersion fixture asserted no constant"); + } + + // ── Reflection plumbing for unions ──────────────────────────────────── + + private static object? RequireUnionValue(object decoded, string path) + { + if (decoded is AhpUnion u) + { + return u.Value; + } + + throw new Xunit.Sdk.XunitException( + $"{Path.GetFileName(path)}: expectVariant[\"\"] requires a union (AhpUnion), got {decoded.GetType().Name}"); + } + + private static object? GetMember(object decoded, string name, string path) + { + // Case-insensitive so a wire field name ("action") resolves the C# + // PascalCase property ("Action"). + var prop = decoded.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.IgnoreCase); + if (prop is null) + { + throw new Xunit.Sdk.XunitException( + $"{Path.GetFileName(path)}: type {decoded.GetType().Name} has no property \"{name}\" (case-insensitive) for expectVariant"); + } + + return prop.GetValue(decoded); + } + + private static SessionStatus ParseStatusFlag(string name, string path) + { + if (Enum.TryParse(name, ignoreCase: false, out SessionStatus flag)) + { + return flag; + } + + throw new Xunit.Sdk.XunitException( + $"{Path.GetFileName(path)}: unknown SessionStatus flag \"{name}\""); + } + + // ── JSON path + equality ────────────────────────────────────────────── + + /// + /// Resolves a dotted path against a JSON element. The empty path returns the + /// element itself (for scalar unions whose whole value is the payload). + /// + private static JsonElement ResolvePath(JsonElement root, string path, string fixturePath) + { + if (path.Length == 0) + { + return root; + } + + JsonElement cur = root; + foreach (string seg in path.Split('.')) + { + if (cur.ValueKind != JsonValueKind.Object || !cur.TryGetProperty(seg, out JsonElement next)) + { + throw new Xunit.Sdk.XunitException( + $"{Path.GetFileName(fixturePath)}: path \"{path}\" — segment \"{seg}\" not found in {cur.GetRawText()}"); + } + + cur = next; + } + + return cur; + } + + private static void AssertJsonEquals(JsonElement want, JsonElement got, string ctx) + { + switch (want.ValueKind) + { + case JsonValueKind.String: + Assert.True( + got.ValueKind == JsonValueKind.String && got.GetString() == want.GetString(), + $"{ctx} — expected string \"{want.GetString()}\", got {Describe(got)}"); + break; + case JsonValueKind.Number: + // Compare numerically so 64-bit values above Int32 are exact and + // 0 == 0.0 etc. Use decimal when both fit; fall back to raw text. + Assert.True( + got.ValueKind == JsonValueKind.Number && NumbersEqual(want, got), + $"{ctx} — expected number {want.GetRawText()}, got {Describe(got)}"); + break; + case JsonValueKind.True: + case JsonValueKind.False: + Assert.True( + got.ValueKind == want.ValueKind, + $"{ctx} — expected {want.ValueKind}, got {Describe(got)}"); + break; + case JsonValueKind.Null: + Assert.True( + got.ValueKind == JsonValueKind.Null, + $"{ctx} — expected null, got {Describe(got)}"); + break; + default: + // Objects / arrays: compare canonical raw text. + Assert.True( + got.GetRawText() == want.GetRawText(), + $"{ctx} — expected {want.GetRawText()}, got {got.GetRawText()}"); + break; + } + } + + private static bool NumbersEqual(JsonElement a, JsonElement b) + { + if (a.TryGetInt64(out long la) && b.TryGetInt64(out long lb)) + { + return la == lb; + } + + if (a.TryGetUInt64(out ulong ua) && b.TryGetUInt64(out ulong ub)) + { + return ua == ub; + } + + if (a.TryGetDecimal(out decimal da) && b.TryGetDecimal(out decimal db)) + { + return da == db; + } + + if (a.TryGetDouble(out double dda) && b.TryGetDouble(out double ddb)) + { + return dda == ddb; + } + + return a.GetRawText() == b.GetRawText(); + } + + private static string Describe(JsonElement e) => + e.ValueKind switch + { + JsonValueKind.String => $"string \"{e.GetString()}\"", + JsonValueKind.Number => $"number {e.GetRawText()}", + JsonValueKind.Object or JsonValueKind.Array => e.GetRawText(), + _ => e.ValueKind.ToString(), + }; + + // ── Fixture file plumbing ───────────────────────────────────────────── + + private static string ReadInputJson(string path, JsonElement root) + { + bool hasRaw = root.TryGetProperty("wireRaw", out JsonElement rawEl); + bool hasWire = root.TryGetProperty("wire", out JsonElement wireEl); + + if (hasRaw == hasWire) + { + throw new Xunit.Sdk.XunitException( + $"{Path.GetFileName(path)}: exactly one of `wire` / `wireRaw` is required for a decode fixture (found wire={hasWire}, wireRaw={hasRaw})."); + } + + if (hasRaw) + { + // `wireRaw` is a JSON string whose CONTENT is the exact bytes to decode. + return rawEl.GetString() + ?? throw new Xunit.Sdk.XunitException($"{Path.GetFileName(path)}: `wireRaw` is null"); + } + + // `wire` is a JSON value; compact-serialize it to bytes. + return JsonSerializer.Serialize(wireEl); + } + + private static string ResolveFixturePath(string prefix) + { + string dir = FindFixtureDir(); + var matches = Directory + .EnumerateFiles(dir, "*.json") + .Where(p => + { + string fn = Path.GetFileNameWithoutExtension(p); + return fn == prefix || fn.StartsWith(prefix + "-", StringComparison.Ordinal); + }) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + + if (matches.Count == 0) + { + throw new FileNotFoundException( + $"no round-trip fixture matches prefix \"{prefix}\" in {dir}"); + } + + if (matches.Count > 1) + { + throw new Xunit.Sdk.XunitException( + $"prefix \"{prefix}\" is ambiguous — matched {matches.Count}: " + + string.Join(", ", matches.Select(Path.GetFileName))); + } + + return matches[0]; + } + + private static IEnumerable EnumerateFixtureFiles() => + Directory + .EnumerateFiles(FindFixtureDir(), "*.json") + .OrderBy(p => p, StringComparer.Ordinal); + + private static string FindFixtureDir() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + string candidate = Path.Combine(dir, "types", "test-cases", "round-trips"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + + throw new DirectoryNotFoundException( + "could not locate types/test-cases/round-trips walking upward from the test assembly"); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripTests.cs new file mode 100644 index 00000000..c39be42c --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripTests.cs @@ -0,0 +1,141 @@ +// Port of clients/go/ahptypes/ahptypes_test.go. +// Tests wire-type round-trips without mocking the JSON engine. +// +// These methods are now THIN, NAMED wrappers over the language-agnostic round- +// trip corpus under types/test-cases/round-trips/*.json. Each wrapper loads its +// fixture by name and delegates to TypesRoundTripFixtures, which decodes with the +// REAL serializer + REAL generated types and asserts the fixture's expectations. +// +// Why keep the named wrappers instead of a single [Theory]? +// * The cross-language test master matrix and the executable parity +// manifest (clients/dotnet/tests/parity-manifest.txt) reference these method +// names (e.g. ActionEnvelope_RoundTrip_*, Customization_UnknownType_*). The +// parity gate greps for each name; collapsing them into one [Theory] would +// drop the named rows AND the [Fact]/[Theory] count below the floor +// (clients/dotnet/tests/MIN_TEST_COUNT). The named wrappers preserve both. +// * The corpus is ALSO run as a whole via TypesRoundTripFixtures.CorpusFixture +// ([Theory] over the dir), so every fixture file is exercised even before a +// named wrapper exists — the named wrappers are the parity-stable surface, +// the dir-walk theory is the completeness guard. +#nullable enable + +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class TypesRoundTripTests +{ + // ── ProtocolVersion ─────────────────────────────────────────────────── + + [Fact] + public void ProtocolVersion_CurrentIsNonEmpty() + => TypesRoundTripFixtures.RunFixtureByName("021"); + + [Fact] + public void ProtocolVersion_SupportedIsNonEmpty() + => TypesRoundTripFixtures.RunFixtureByName("022"); + + [Fact] + public void ProtocolVersion_FirstSupportedEqualsCurrentVersion() + => TypesRoundTripFixtures.RunFixtureByName("023"); + + // ── ActionEnvelope round-trip ───────────────────────────────────────── + + [Fact] + public void ActionEnvelope_RoundTrip_SessionTitleChanged() + => TypesRoundTripFixtures.RunFixtureByName("001"); + + // ── Unknown discriminator preserved verbatim ────────────────────────── + + [Fact] + public void StateAction_UnknownVariant_PreservedVerbatim() + => TypesRoundTripFixtures.RunFixtureByName("002"); + + // ── SessionStatus bitset ────────────────────────────────────────────── + + [Fact] + public void SessionStatus_HasFlag_Works() + => TypesRoundTripFixtures.RunFixtureByName("004"); + + [Fact] + public void SessionStatus_UnknownBitsSurviveRoundTrip() + => TypesRoundTripFixtures.RunFixtureByName("005"); + + // ── StringOrMarkdown plain and object forms ─────────────────────────── + + [Theory] + [InlineData("plain", "006")] + [InlineData("object", "007")] + public void StringOrMarkdown_RoundTrip(string _, string fixture) + => TypesRoundTripFixtures.RunFixtureByName(fixture); + + // ── JsonRpcMessage all four shapes ──────────────────────────────────── + + [Theory] + [InlineData("request", "008")] + [InlineData("notification", "009")] + [InlineData("success", "010")] + [InlineData("error", "011")] + public void JsonRpcMessage_Discriminator(string _, string fixture) + => TypesRoundTripFixtures.RunFixtureByName(fixture); + + // ── Customization unknown discriminator does not throw ───────────────── + + [Fact] + public void Customization_UnknownType_DoesNotThrow() + => TypesRoundTripFixtures.RunFixtureByName("003"); + + // ── Changeset-operation target dispatches on its `kind` discriminator ── + + [Fact] + public void ChangesetOperationTarget_DispatchesOnKind() + { + // The original test exercised BOTH the resource and range variants in one + // method; the corpus splits them into two fixtures (012 resource, 013 + // range). Run both here so this manifest-named method covers the same + // ground, and the dir-walk theory covers each fixture independently too. + TypesRoundTripFixtures.RunFixtureByName("012"); + TypesRoundTripFixtures.RunFixtureByName("013"); + } + + // ── SessionInputQuestion "number" and "integer" both decode typed ───── + + [Fact] + public void SessionInputQuestion_NumberAndIntegerKinds() + { + // Original method asserted both kinds; fixtures 014 (number) + 015 + // (integer) preserve the same two vectors. + TypesRoundTripFixtures.RunFixtureByName("014"); + TypesRoundTripFixtures.RunFixtureByName("015"); + } + + // ── 64-bit numeric field survives values above Int32.MaxValue ───────── + + [Fact] + public void Number_LongAboveInt32Max_Preserved() + => TypesRoundTripFixtures.RunFixtureByName("016"); + + // ── Unknown wire keys are ignored on decode ─────────────────────────── + + [Fact] + public void UnknownWireKeys_IgnoredOnDecode() + => TypesRoundTripFixtures.RunFixtureByName("017"); + + // ── Nested optional struct round-trips when null ────────────────────── + + [Fact] + public void NestedOptionalStruct_RoundTripsWhenNull() + => TypesRoundTripFixtures.RunFixtureByName("018"); + + // ── Channel-scoped notification preserves its channel URI ───────────── + + [Fact] + public void ChannelScopedNotification_CarriesUri() + => TypesRoundTripFixtures.RunFixtureByName("019"); + + // ── Partial summary with all-null payload round-trips ───────────────── + + [Fact] + public void PartialSummary_AllNullPayload_RoundTrips() + => TypesRoundTripFixtures.RunFixtureByName("020"); +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs new file mode 100644 index 00000000..03c546f8 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs @@ -0,0 +1,290 @@ +// Phase-1 parity tests for matrix group E (transport) — real WebSocket path. +// These are the no-mock centrepiece: a REAL OS loopback socket via +// System.Net.HttpListener, a REAL WebSocket handshake, and the REAL +// WebSocketTransport + ClientWebSocket. Nothing here is faked — the server is +// a genuine HttpListener accepting a genuine WebSocket upgrade. +#nullable enable + +using System; +using System.Collections.Specialized; // NameValueCollection (captured request headers) +using System.Net; // HttpListener, IPEndPoint +using System.Net.Sockets; // TcpListener (free-port picking) +using System.Net.WebSockets; // WebSocket, WebSocketMessageType, ... +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.WebSockets; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class WebSocketTransportTests +{ + // ── Loopback WebSocket server harness ───────────────────────────────── + + /// + /// A real loopback WebSocket server built on . + /// Picks a free 127.0.0.1 port (HttpListener can't bind ephemeral port 0), + /// accepts exactly one WebSocket upgrade, and hands the accepted server + /// to the supplied handler. + /// + private sealed class LoopbackWsServer : IAsyncDisposable + { + private readonly HttpListener _listener; + private readonly TaskCompletionSource _requestHeaders = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Port { get; } + public Uri WsUri => new($"ws://127.0.0.1:{Port}/"); + + /// + /// Completes with the HTTP request headers of the accepted upgrade — + /// captured from the real BEFORE the + /// WebSocket handshake completes. Lets a test prove a custom header set + /// via actually + /// reached the server on the wire. + /// + public Task RequestHeaders => _requestHeaders.Task; + + private LoopbackWsServer(HttpListener listener, int port) + { + _listener = listener; + Port = port; + } + + /// Picks a free loopback port, starts an HttpListener on it, and returns the server. + public static LoopbackWsServer Start() + { + int port = FreeLoopbackPort(); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + // If HttpListener can't bind (locked-down box / perms), this throws + // HttpListenerException — surfaced to the caller, NOT swallowed. + listener.Start(); + return new LoopbackWsServer(listener, port); + } + + /// + /// Accepts exactly one connection; if it's a WebSocket upgrade, invokes + /// with the accepted server socket, then + /// disposes it. Returns a Task that completes when the handler returns. + /// + public Task AcceptOneAsync(Func handler, CancellationToken ct) + { + return Task.Run(async () => + { + var ctx = await _listener.GetContextAsync().ConfigureAwait(false); + // Capture the request headers from the real upgrade request + // before completing the handshake, so a test can assert a custom + // header (e.g. Authorization) was forwarded on the wire. + _requestHeaders.TrySetResult(ctx.Request.Headers); + if (!ctx.Request.IsWebSocketRequest) + { + ctx.Response.StatusCode = 400; + ctx.Response.Close(); + throw new InvalidOperationException("expected a WebSocket upgrade request"); + } + + var wsCtx = await ctx.AcceptWebSocketAsync(subProtocol: null).ConfigureAwait(false); + var serverWs = wsCtx.WebSocket; + try + { + await handler(serverWs, ct).ConfigureAwait(false); + } + finally + { + serverWs.Dispose(); + } + }, ct); + } + + /// + /// Binds a TcpListener on 127.0.0.1:0, reads the OS-assigned port, then + /// releases it. Small race window, acceptable for a loopback test. + /// + private static int FreeLoopbackPort() + { + var tcp = new TcpListener(IPAddress.Loopback, 0); + tcp.Start(); + try { return ((IPEndPoint)tcp.LocalEndpoint).Port; } + finally { tcp.Stop(); } + } + + public ValueTask DisposeAsync() + { + try { _listener.Stop(); } catch { /* best effort */ } + try { ((IDisposable)_listener).Dispose(); } catch { /* best effort */ } + return ValueTask.CompletedTask; + } + } + + // ── E: real-socket handshake (HttpListener loopback) ────────────────── + // Stands up a real loopback WebSocket server, dials it with the production + // WebSocketTransport.ConnectAsync (real ClientWebSocket + real handshake), + // round-trips one text frame (server echoes), and asserts the payload. + [Fact] + public async Task NativeTransport_PerformsHandshakeAndRoundTripsText() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = LoopbackWsServer.Start(); + + // Server: receive one text frame and echo it back. + var serverTask = server.AcceptOneAsync(async (serverWs, ct) => + { + var buf = new byte[4096]; + var result = await serverWs.ReceiveAsync(new ArraySegment(buf), ct).ConfigureAwait(false); + Assert.Equal(WebSocketMessageType.Text, result.MessageType); + await serverWs.SendAsync( + new ArraySegment(buf, 0, result.Count), + WebSocketMessageType.Text, + endOfMessage: true, + ct).ConfigureAwait(false); + // Cleanly close after the echo. + await serverWs.CloseAsync(WebSocketCloseStatus.NormalClosure, "", ct).ConfigureAwait(false); + }, cts.Token); + + const string payload = "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}"; + + await using var transport = await WebSocketTransport.ConnectAsync(server.WsUri, cancellationToken: cts.Token); + await transport.SendAsync(TransportMessage.FromText(payload), cts.Token); + + var got = await transport.ReceiveAsync(cts.Token); + Assert.Equal(TransportFrame.Text, got.Frame); + Assert.Equal(payload, got.Text); + + await transport.CloseAsync(cts.Token); + await serverTask; + } + + // ── E: custom header forwarding (ConfigureSocket → wire) ────────────── + // Mirrors Swift NWConnectionWebSocketTransportTests + // `testNativeTransportPerformsHandshakeAndRoundTripsText`, which dials with + // headers: ["Authorization": "Bearer test-token"] and asserts the server + // observed `authorization == "Bearer test-token"`. Here the production + // WebSocketTransportOptions.ConfigureSocket hook sets the header on the real + // ClientWebSocket before ConnectAsync; the loopback server captures the real + // upgrade request headers and we assert the token arrived on the wire. + [Fact] + public async Task NativeTransport_ForwardsCustomRequestHeader() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = LoopbackWsServer.Start(); + + // Server: receive one text frame, echo it, then close cleanly. + var serverTask = server.AcceptOneAsync(async (serverWs, ct) => + { + var buf = new byte[4096]; + var result = await serverWs.ReceiveAsync(new ArraySegment(buf), ct).ConfigureAwait(false); + await serverWs.SendAsync( + new ArraySegment(buf, 0, result.Count), + WebSocketMessageType.Text, + endOfMessage: true, + ct).ConfigureAwait(false); + await serverWs.CloseAsync(WebSocketCloseStatus.NormalClosure, "", ct).ConfigureAwait(false); + }, cts.Token); + + const string headerName = "Authorization"; + const string headerValue = "Bearer test-token"; + + var options = new WebSocketTransportOptions + { + ConfigureSocket = ws => ws.Options.SetRequestHeader(headerName, headerValue), + }; + + await using var transport = await WebSocketTransport.ConnectAsync( + server.WsUri, options, cancellationToken: cts.Token); + + // A frame must flow so the handshake fully completes and the server has + // accepted the upgrade (the header is captured at upgrade time). + await transport.SendAsync(TransportMessage.FromText("{\"jsonrpc\":\"2.0\"}"), cts.Token); + var echoed = await transport.ReceiveAsync(cts.Token); + Assert.Equal(TransportFrame.Text, echoed.Frame); + + // The custom header reached the server on the real upgrade request. + var headers = await server.RequestHeaders.WaitAsync(cts.Token); + Assert.Equal(headerValue, headers[headerName]); + + await transport.CloseAsync(cts.Token); + await serverTask; + } + + // ── E: reject unsupported scheme ────────────────────────────────────── + // ClientWebSocket rejects non-ws/wss URIs. A short-timeout CTS guards + // against any hang. We catch broadly and assert an exception was raised. + [Fact] + public async Task NativeTransport_RejectsUnsupportedScheme() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var ex = await Record.ExceptionAsync(async () => + await WebSocketTransport.ConnectAsync(new Uri("http://localhost:1/"), cancellationToken: cts.Token)); + + Assert.NotNull(ex); + // ClientWebSocket.ConnectAsync throws ArgumentException for a non-ws + // scheme (it may surface wrapped in other exception types across + // runtimes); accept the broad transport-error family but NOT a clean + // return. + Assert.True( + ex is ArgumentException + or InvalidOperationException + or WebSocketException + or NotSupportedException, + $"Expected a scheme-rejection exception, got {ex.GetType().Name}: {ex.Message}"); + } + + // ── E: clean close drains null ──────────────────────────────────────── + // Method name is historical (mirrors the Go test name). The .NET contract + // is throw-not-null: on a CLEAN remote close, WebSocketTransport.ReceiveAsync + // throws TransportClosedException (see WebSocketTransport.cs ~line 164), it + // does NOT return null. Assert the actual .NET behaviour. + [Fact] + public async Task WsTransport_CleanClose_DrainsRecvNull() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = LoopbackWsServer.Start(); + + // Server cleanly closes immediately after the handshake. + var serverTask = server.AcceptOneAsync(async (serverWs, ct) => + { + await serverWs.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", ct).ConfigureAwait(false); + }, cts.Token); + + await using var transport = await WebSocketTransport.ConnectAsync(server.WsUri, cancellationToken: cts.Token); + + await Assert.ThrowsAsync( + async () => await transport.ReceiveAsync(cts.Token)); + + await serverTask; + } + + // ── E: abnormal close error ─────────────────────────────────────────── + // On an ABNORMAL close (server aborts the socket without a close frame), + // WebSocketTransport.ReceiveAsync wraps the WebSocketException into a thrown + // Exception ("ahp: websocket closed: ...", see WebSocketTransport.cs ~145). + // Assert that an exception is raised — i.e. NOT a clean TransportClosedException + // drain. + [Fact] + public async Task WsTransport_AbnormalClose_RaisesTransportError() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = LoopbackWsServer.Start(); + + // Server abruptly aborts the socket (no close handshake) right after accept. + var serverTask = server.AcceptOneAsync((serverWs, ct) => + { + serverWs.Abort(); + return Task.CompletedTask; + }, cts.Token); + + await using var transport = await WebSocketTransport.ConnectAsync(server.WsUri, cancellationToken: cts.Token); + + var ex = await Record.ExceptionAsync(async () => await transport.ReceiveAsync(cts.Token)); + Assert.NotNull(ex); + // An abnormal close must surface as a fault, not a clean + // TransportClosedException drain. WebSocketTransport.ReceiveAsync wraps + // WebSocketException into a plain Exception ("ahp: websocket closed:"). + Assert.IsNotType(ex); + + await serverTask; + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/interop/independent-host-session-convergence.json b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/independent-host-session-convergence.json new file mode 100644 index 00000000..64faa1ae --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/independent-host-session-convergence.json @@ -0,0 +1,102 @@ +{ + "initial": { + "summary": { + "resource": "copilot:/interop-session", + "provider": "copilot", + "title": "Initial title", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "creating", + "turns": [] + }, + "envelopes": [ + { + "channel": "ahp-session:/interop", + "action": { + "type": "session/titleChanged", + "title": "Session One" + }, + "serverSeq": 1, + "origin": "host", + "meta": { + "modifiedAt": 2001 + } + }, + { + "channel": "ahp-session:/interop", + "action": { + "type": "session/isReadChanged", + "isRead": true + }, + "serverSeq": 2, + "origin": "host", + "meta": { + "modifiedAt": 2002 + } + }, + { + "channel": "ahp-session:/interop", + "action": { + "type": "session/activityChanged", + "activity": "thinking" + }, + "serverSeq": 3, + "origin": "host", + "meta": { + "modifiedAt": 2003 + } + }, + { + "channel": "ahp-session:/interop", + "action": { + "type": "session/titleChanged", + "title": "Session One \u2014 renamed" + }, + "serverSeq": 4, + "origin": "host", + "meta": { + "modifiedAt": 2004 + } + }, + { + "channel": "ahp-session:/interop", + "action": { + "type": "session/isReadChanged", + "isRead": false + }, + "serverSeq": 5, + "origin": "host", + "meta": { + "modifiedAt": 2005 + } + }, + { + "channel": "ahp-session:/interop", + "action": { + "type": "session/isArchivedChanged", + "isArchived": true + }, + "serverSeq": 6, + "origin": "host", + "meta": { + "modifiedAt": 2006 + } + } + ], + "final": { + "summary": { + "resource": "copilot:/interop-session", + "provider": "copilot", + "title": "Session One \u2014 renamed", + "status": 65, + "createdAt": 1000, + "modifiedAt": 2006, + "activity": "thinking" + }, + "lifecycle": "creating", + "turns": [] + }, + "_source": "Captured from an independent AHP WebSocket host running the canonical TypeScript sessionReducer (independent implementation)." +} diff --git a/clients/dotnet/tests/MIN_TEST_COUNT b/clients/dotnet/tests/MIN_TEST_COUNT new file mode 100644 index 00000000..7bfa963a --- /dev/null +++ b/clients/dotnet/tests/MIN_TEST_COUNT @@ -0,0 +1,5 @@ +120 +# A floor, not a pin: the minimum number of discrete [Fact]/[Theory] +# methods, kept below the current count so legitimate [Theory] consolidation +# does not trip the gate. Raise with check-test-parity.sh --bump after adding +# tests. Guards against silent test deletions, not against refactoring. diff --git a/clients/dotnet/tests/parity-manifest.txt b/clients/dotnet/tests/parity-manifest.txt new file mode 100644 index 00000000..da48ddbb --- /dev/null +++ b/clients/dotnet/tests/parity-manifest.txt @@ -0,0 +1,135 @@ +# .NET parity manifest — the expected test methods, in executable form. +# +# This is the machine-checked form of the cross-language test parity matrix for +# the .NET client. The parity gate (scripts/check-test-parity.sh) greps each +# MethodName across clients/dotnet/tests/; any that are absent are reported as +# "missing", grouped by the `group` column, so a failing gate names exactly +# which parity tests still need writing. +# +# Format: MethodName | TargetSuite | group | matrix-row +# - lines starting with '#' and blank lines are ignored +# - group 1 = test-only gaps: the feature already exists, only the test is new +# - group 2 = feature+test gaps: new production code, then its tests +# +# The pre-existing tests are not listed here — they are already present; this +# file enumerates only the parity tests to be ADDED. Deletion of an existing +# test is caught separately by the count floor (tests/MIN_TEST_COUNT). + +# ===================================================================== +# Group 1 — test-only gaps (the feature already exists) +# ===================================================================== + +# -- A. Reducer semantics +ClientDispatchable_TrueForUserChannelAction | NativeReducerTests | 1 | A: clientDispatchable true +ClientDispatchable_FalseForHostOnlyAction | NativeReducerTests | 1 | A: clientDispatchable false + +# -- B. Types / serialization +Customization_UnknownType_DoesNotThrow | TypesRoundTripTests | 1 | B: customization unknown type +ChangesetOperationTarget_DispatchesOnKind | TypesRoundTripTests | 1 | B: changeset target by kind +SessionInputQuestion_NumberAndIntegerKinds | TypesRoundTripTests | 1 | B: input-question number+integer +Number_LongAboveInt32Max_Preserved | TypesRoundTripTests | 1 | B: long > Int32.Max preserved +UnknownWireKeys_IgnoredOnDecode | TypesRoundTripTests | 1 | B: unknown wire keys ignored +NestedOptionalStruct_RoundTripsWhenNull | TypesRoundTripTests | 1 | B: nested optional null round-trip +ChannelScopedNotification_CarriesUri | TypesRoundTripTests | 1 | B: channel-scoped notif uri +PartialSummary_AllNullPayload_RoundTrips | TypesRoundTripTests | 1 | B: partial summary all-null + +# -- C. ReconnectPolicy +Jitter_Zero_YieldsBaseDelay | ReconnectPolicyTests | 1 | C: jitter zero -> base delay +UnboundedPolicy_NeverExhausts | ReconnectPolicyTests | 1 | C: unbounded never exhausts +ImmediateBackoff_IsZero | ReconnectPolicyTests | 1 | C: immediate backoff zero + +# -- D. Single-host client (AhpClient) +Initialize_SnapshotDeliveredInResult | ClientTests | 1 | D: initialize snapshot in result +Subscribe_RoundTrip_DeliversSnapshot | ClientTests | 1 | D: subscribe round-trip + snapshot +AttachSubscription_DeliversWithoutRoundTrip | ClientTests | 1 | D: attachSubscription +MultipleSubscriptions_SameUri_EachReceiveEvent | ClientTests | 1 | D: multi-sub same uri +Unsubscribe_FinishesStream | ClientTests | 1 | D: unsubscribe finishes stream +Dispatch_EmitsActionNotification_WithClientSeq | ClientTests | 1 | D: dispatch clientSeq +RequestError_MapsToAhpRpcException | ClientTests | 1 | D: json-rpc error -> exception +Request_Timeout_ThrowsRpcTimeout | ClientTests | 1 | D: request timeout +InboundBinaryFrame_Decoded | ClientTests | 1 | D: inbound binary frame +ServerRequest_NoHandler_RepliesMethodNotFound | ClientTests | 1 | D: server req -> MethodNotFound +ServerRequest_Handler_RepliesResult | ClientTests | 1 | D: server req -> handler result +PostShutdown_Operations_ThrowClientClosed | ClientTests | 1 | D: post-shutdown throws + +# -- E. Transport +InMemoryTransport_DeliversBothDirections | TransportTests | 1 | E: in-mem both directions +InMemoryTransport_Close_EndsBothRecv | TransportTests | 1 | E: close ends recv +InMemoryTransport_SendAfterClose_Throws | TransportTests | 1 | E: send after close throws +TransportMessage_RoundTrip_Notification | TransportTests | 1 | E: TransportMessage round-trip +WsTransport_CleanClose_DrainsRecvNull | WebSocketTransportTests | 1 | E: clean close drains null +WsTransport_AbnormalClose_RaisesTransportError | WebSocketTransportTests | 1 | E: abnormal close error +NativeTransport_PerformsHandshakeAndRoundTripsText | WebSocketTransportTests | 1 | E: real-socket handshake (HttpListener loopback) +NativeTransport_RejectsUnsupportedScheme | WebSocketTransportTests | 1 | E: reject unsupported scheme + +# -- F. ClientIdStore (in-memory + key encoding) +InMemoryClientIdStore_RoundTrips | ClientIdStoreTests | 1 | F: in-mem round-trip +InMemoryClientIdStore_Overwrites | ClientIdStoreTests | 1 | F: in-mem overwrite +HostedResourceKey_UnreservedPassThrough | ClientIdStoreTests | 1 | F: key unreserved pass-through +HostedResourceKey_ReservedPercentEscaped | ClientIdStoreTests | 1 | F: key reserved %-escaped + +# -- G. MultiHostStateMirror +StateMirror_RootStatesIsolatedPerHost | MultiHostStateMirrorTests | 1 | G: roots isolated per host +StateMirror_SessionUriCollision_NoClobber | MultiHostStateMirrorTests | 1 | G: uri collision no clobber +StateMirror_ApplyRootAction_UpdatesOnlyTarget | MultiHostStateMirrorTests | 1 | G: root action targets one +StateMirror_AppliesHostSubscriptionEvent | MultiHostStateMirrorTests | 1 | G: forwards subscription event +StateMirror_ResetHost_DropsOnlyThatHost | MultiHostStateMirrorTests | 1 | G: reset host drops one + +# -- H. MultiHostClient (test-only) +MultiHost_TwoHosts_RegisterAndConnectIndependently | MultiHostClientTests | 1 | H: two hosts independent +MultiHost_Events_CarryHostIdAndResource | MultiHostClientTests | 1 | H: events tagged hostId +MultiHost_Reconnect_ReplaysActionsWithAdvancedSeq | MultiHostClientTests | 1 | H: reconnect replay + +# ===================================================================== +# Group 2 — feature+test gaps (new production code, then its tests) +# ===================================================================== + +# -- D. connection-state + keep-alive +ConnectionState_TransitionsThroughStateChanges | ClientTests | 2 | D: connectionState/stateChanges +KeepAlive_PingsWhenCapable | ClientTests | 2 | D: keep-alive pings +KeepAlive_DisabledByConfig | ClientTests | 2 | D: keep-alive disabled +KeepAlive_DisconnectsOnPingFailure | ClientTests | 2 | D: keep-alive ping failure + +# -- F. FileClientIdStore (new feature) +FileClientIdStore_RoundTripsAndSurvivesInstances | FileClientIdStoreTests | 2 | F: file round-trip + persist +FileClientIdStore_KeysPerHost | FileClientIdStoreTests | 2 | F: file per-host keying +FileClientIdStore_HandlesUrlUnsafeId | FileClientIdStoreTests | 2 | F: file url-unsafe id +FileClientIdStore_ConcurrentWrites_NoCorruption | FileClientIdStoreTests | 2 | F: file concurrent writes +FileClientIdStore_FileIsOwnerOnlyOnUnix | FileClientIdStoreTests | 2 | F: file owner-only perms + +# -- H. aggregated views, per-host streams, manual reconnect, typed errors +MultiHost_DuplicateHostId_ThrowsDuplicateHostException | MultiHostClientTests | 2 | H: duplicate host typed error +MultiHost_AggregatedSessions_SortedAndHostLabeled | MultiHostClientTests | 2 | H: aggregatedSessions +MultiHost_AggregatedAgents_TaggedByHost | MultiHostClientTests | 2 | H: aggregatedAgents +MultiHost_HostSnapshots_Stream | MultiHostClientTests | 2 | H: host snapshots stream +MultiHost_SessionSummaries_Stream | MultiHostClientTests | 2 | H: session summaries stream +MultiHost_HostEvents_Live | MultiHostClientTests | 2 | H: events(host,uri) live +MultiHost_HostEvents_SurvivesReconnect | MultiHostClientTests | 2 | H: events survive reconnect +MultiHost_HostEvents_FinishesOnUnsubscribe | MultiHostClientTests | 2 | H: events finish on unsubscribe +MultiHost_ReconnectHost_WakesExhaustedHost | MultiHostClientTests | 2 | H: reconnectHost wakes exhausted +MultiHost_ReconnectAllUnavailable_SkipsConnected | MultiHostClientTests | 2 | H: reconnectAll skips connected +MultiHost_ReconnectAllUnavailable_ReportsPerHostErrors | MultiHostClientTests | 2 | H: reconnectAll per-host errors +MultiHost_ReconnectHost_AbortsSlowFactory | MultiHostClientTests | 2 | H: reconnect aborts slow factory +MultiHost_UnknownHost_Subscribe_Throws | MultiHostClientTests | 2 | H: unknown-host typed error (subscribe) +MultiHost_UnknownHost_Dispatch_Throws | MultiHostClientTests | 2 | H: unknown-host typed error (dispatch) +MultiHost_NotConnected_Dispatch_Throws | MultiHostClientTests | 2 | H: not-connected typed error +MultiHost_HandleAfterRemove_ThrowsHostShutDown | MultiHostClientTests | 2 | H: HostShutDown after remove + +# ===================================================================== +# Group 2 (cont.) — cross-client parity gaps +# Concepts the other clients (Swift / TS) test but .NET did not. Each is a +# REAL no-mock test over the production AhpClient / WebSocketTransport / +# Subscription back-pressure path; see clients/swift + clients/typescript +# for the mirrored reference vectors. +# ===================================================================== + +# -- E. transport: custom request-header forwarding (mirrors Swift NW transport) +NativeTransport_ForwardsCustomRequestHeader | WebSocketTransportTests | 2 | E: ConfigureSocket header on wire + +# -- D. in-flight request cancellation (mirrors Swift AHPClientTests) +Request_CancelDuringFlight_ThrowsAndClearsPending | ClientTests | 2 | D: cancel mid-flight clears pending +Request_PreCancelledToken_FastFailsWithoutMintingIdOrSending | ClientTests | 2 | D: pre-cancelled fast-fail (no id/bytes) +Request_HappyPath_StillResolves | ClientTests | 2 | D: happy-path still resolves + +# -- D. subscription back-pressure: drop-oldest + laggard fast-forward + no replay (mirrors TS async-queue) +Subscription_BoundedBuffer_DropsOldest_FastForwards_NoReplay | ClientTests | 2 | D: bounded buffer drop-oldest + no replay diff --git a/package.json b/package.json index dfd3842d..fa9ff1fb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "generate:kotlin": "tsx scripts/generate.ts --kotlin", "generate:typescript": "tsx scripts/generate.ts --typescript", "generate:go": "tsx scripts/generate.ts --go", + "generate:dotnet": "tsx scripts/generate.ts --dotnet", "generate:metadata": "tsx scripts/generate.ts --metadata", "verify:release-metadata": "tsx scripts/verify-release-metadata.ts", "verify:changelog": "tsx scripts/verify-changelog.ts", diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts new file mode 100644 index 00000000..c59d1d37 --- /dev/null +++ b/scripts/generate-csharp.ts @@ -0,0 +1,1733 @@ +/** + * C# / .NET Generator — Generates C# type definitions for the + * `Microsoft.AgentHostProtocol.Abstractions` package from the + * TypeScript source of truth parsed via ts-morph. + * + * Output: clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/ + * {State,Actions,Commands,Notifications,Errors,Messages,Version}.generated.cs + * + * Mirrors the structure of `generate-go.ts` — the curated struct / enum / + * union lists are identical because they are protocol-driven, not + * language-driven. Only the emit functions differ. The generated files + * are always overwritten; the hand-written files under `Common/` and the + * interface seams (`ITransport`, `IAhpSerializer`) are left alone. + * + * Conventions: + * - Wire field names (camelCase) are preserved exactly via + * `[JsonPropertyName("...")]`. C# property identifiers are PascalCase. + * - Required fields → non-nullable, always serialized. + * - Optional fields → nullable (`T?`) + `[JsonIgnore(Condition = + * JsonIgnoreCondition.WhenWritingNull)]`. + * - TS `number` → C# `long` unless `@format float` → `double`. + * - TS `unknown` / `object` → `JsonElement`; `Record` + * → `Dictionary`. + * - Discriminated unions are emitted as a sealed wrapper class deriving + * from `AhpUnion` (a hand-written base carrying `object? Value`) plus + * a generated `UnionConverter` subclass. Unknown discriminator + * values surface as a raw `JsonElement` stored in `Value`, preserved + * verbatim for loss-free round-trips. + * - String enums map wire values via `[WireValue("...")]` + the + * hand-written `WireEnumConverter`. Bitset enums (numeric values) + * become `[Flags] enum : uint` and serialize as their numeric value + * (System.Text.Json default), so unknown future bits round-trip. + */ + +import { + Project, + InterfaceDeclaration, + EnumDeclaration, + PropertySignature, +} from 'ts-morph'; +import fs from 'fs'; +import path from 'path'; +import { findProtocolSourceFiles } from './find-protocol-sources.js'; +import { readProtocolVersions } from './read-protocol-versions.js'; + +const NAMESPACE = 'Microsoft.AgentHostProtocol'; + +function fileHeader(extraUsings: string[] = []): string { + const usings = [ + 'using System;', + 'using System.Collections.Generic;', + 'using System.Text.Json;', + 'using System.Text.Json.Serialization;', + ...extraUsings, + ]; + return ( + '// \n' + + '// Generated from types/*.ts — do not edit.\n' + + '//\n' + + '// Regenerate with: npm run generate:dotnet\n' + + '// \n' + + '#nullable enable\n\n' + + usings.join('\n') + + '\n\n' + + `namespace ${NAMESPACE};\n` + ); +} + +// ─── Name Mapping ──────────────────────────────────────────────────────────── + +/** Strips the I prefix from interface names: IRootState → RootState */ +function stripIPrefix(tsName: string): string { + if ( + tsName.length > 1 && + tsName[0] === 'I' && + tsName[1] === tsName[1].toUpperCase() && + tsName[1] !== tsName[1].toLowerCase() + ) { + return tsName.substring(1); + } + return tsName; +} + +const CS_RESERVED = new Set([ + 'abstract', 'as', 'base', 'bool', 'break', 'byte', 'case', 'catch', 'char', + 'checked', 'class', 'const', 'continue', 'decimal', 'default', 'delegate', + 'do', 'double', 'else', 'enum', 'event', 'explicit', 'extern', 'false', + 'finally', 'fixed', 'float', 'for', 'foreach', 'goto', 'if', 'implicit', + 'in', 'int', 'interface', 'internal', 'is', 'lock', 'long', 'namespace', + 'new', 'null', 'object', 'operator', 'out', 'override', 'params', 'private', + 'protected', 'public', 'readonly', 'ref', 'return', 'sbyte', 'sealed', + 'short', 'sizeof', 'stackalloc', 'static', 'string', 'struct', 'switch', + 'this', 'throw', 'true', 'try', 'typeof', 'uint', 'ulong', 'unchecked', + 'unsafe', 'ushort', 'using', 'virtual', 'void', 'volatile', 'while', +]); + +/** Escape a C# identifier that collides with a keyword via the `@` prefix. */ +function csIdent(name: string): string { + return CS_RESERVED.has(name) ? `@${name}` : name; +} + +/** camelCase/snake_case/whatever → PascalCase. */ +function toPascalCase(name: string): string { + if (!name) return name; + const cleaned = name.replace(/^_+/, ''); + const segments = cleaned.split(/[_-]/).filter(Boolean); + if (segments.length > 1) { + return segments.map((s) => s[0].toUpperCase() + s.slice(1)).join(''); + } + return cleaned[0].toUpperCase() + cleaned.slice(1); +} + +/** PascalCase enum-variant name from a free-form string. */ +function toEnumVariant(value: string): string { + const cleaned = value.replace(/[^a-zA-Z0-9]+/g, ' ').trim(); + return cleaned + .split(' ') + .filter(Boolean) + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(''); +} + +// ─── Type Mapping ──────────────────────────────────────────────────────────── + +const requiredPartialStructs = new Set(); + +function partialCsName(tsInterfaceName: string): string { + return `Partial${stripIPrefix(tsInterfaceName)}`; +} + +/** Map a TypeScript type expression to a C# type expression (no nullability). */ +function mapType(tsType: string): string { + tsType = tsType.replace(/import\([^)]+\)\./g, '').trim(); + + while (tsType.startsWith('(') && tsType.endsWith(')')) { + tsType = tsType.slice(1, -1).trim(); + } + + if (tsType === 'string') return 'string'; + if (tsType === 'number') return 'long'; + if (tsType === 'boolean') return 'bool'; + if (tsType === 'unknown') return 'JsonElement'; + if (tsType === 'object') return 'JsonElement'; + if (tsType === 'true' || tsType === 'false') return 'bool'; + + if (tsType === 'URI') return 'string'; + if (tsType === 'StringOrMarkdown') return 'StringOrMarkdown'; + + // ChildCustomizationType is a TS-only subset alias of CustomizationType. + if (tsType === 'ChildCustomizationType') return 'CustomizationType'; + + if (tsType === 'SessionStatus') return 'SessionStatus'; + + if ( + tsType === 'IRootState | ISessionState' || + tsType === 'IRootState | ISessionState | ITerminalState' || + tsType === 'RootState | SessionState' || + tsType === 'RootState | SessionState | TerminalState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' + ) { + return 'SnapshotState'; + } + + // `T | null` — handled at the property level (nullability); strip here. + const nullMatch = tsType.match(/^(.+?)\s*\|\s*null$/); + if (nullMatch) { + return mapType(nullMatch[1]); + } + + const undefMatch = tsType.match(/^(.+?)\s*\|\s*undefined$/); + if (undefMatch) return mapType(undefMatch[1]); + + const arrayMatch = tsType.match(/^(.+)\[\]$/); + if (arrayMatch) return `List<${mapType(arrayMatch[1])}>`; + + const arrayGenericMatch = tsType.match(/^Array<(.+)>$/); + if (arrayGenericMatch) return `List<${mapType(arrayGenericMatch[1])}>`; + + const recordMatch = tsType.match(/^Record$/); + if (recordMatch) { + const inner = recordMatch[1].trim(); + // `Record` is the MCP-style marker for "empty object"; + // treat it like `Record` so the wire `{}` round-trips. + if (inner === 'unknown' || inner === 'never') return 'Dictionary'; + return `Dictionary`; + } + + const partialMatch = tsType.match(/^Partial<(\w+)>$/); + if (partialMatch) { + requiredPartialStructs.add(partialMatch[1]); + return partialCsName(partialMatch[1]); + } + + const enumUnionMatch = tsType.match(/^(\w+)\.\w+(\s*\|\s*\1\.\w+)*$/); + if (enumUnionMatch) return stripIPrefix(enumUnionMatch[1]); + + const enumMemberMatch = tsType.match(/^(\w+)\.(\w+)$/); + if (enumMemberMatch) return stripIPrefix(enumMemberMatch[1]); + + if (tsType.startsWith("'") && tsType.endsWith("'")) return 'string'; + if (/^'[^']*'(\s*\|\s*'[^']*')+$/.test(tsType)) return 'string'; + + if (tsType.startsWith('{')) return 'JsonElement'; + + return stripIPrefix(tsType); +} + +// Value-type detection lives in `csIsValueType` below; it consults the +// `ALL_ENUM_NAMES` set so optional enum-typed properties get a `?`. + +// ─── Property Extraction ───────────────────────────────────────────────────── + +interface CsProp { + csName: string; + wireName: string; + csType: string; + optional: boolean; + doc: string; + isLiteralDiscriminant: boolean; + literalValue?: string; +} + +function getPropertyType(prop: PropertySignature): string { + const typeNode = prop.getTypeNode(); + if (typeNode) return typeNode.getText(); + return prop.getType().getText(prop); +} + +function getPropertyDoc(prop: PropertySignature): string { + const jsDocs = prop.getJsDocs(); + if (jsDocs.length === 0) return ''; + return jsDocs[0].getDescription().trim(); +} + +function hasFormatFloat(prop: PropertySignature): boolean { + for (const doc of prop.getJsDocs()) { + for (const tag of doc.getTags()) { + if (tag.getTagName() === 'format' && tag.getCommentText()?.trim() === 'float') { + return true; + } + } + } + return false; +} + +function getAllProperties(iface: InterfaceDeclaration, project: Project): PropertySignature[] { + const props: PropertySignature[] = []; + for (const ext of iface.getExtends()) { + const baseName = ext.getExpression().getText(); + const baseIface = findInterface(project, baseName); + if (baseIface) { + props.push(...getAllProperties(baseIface, project)); + } + } + props.push(...iface.getProperties()); + return props; +} + +function findInterface(project: Project, name: string): InterfaceDeclaration | undefined { + for (const sf of project.getSourceFiles()) { + const iface = sf.getInterface(name); + if (iface) return iface; + } + return undefined; +} + +function findEnum(project: Project, name: string): EnumDeclaration | undefined { + for (const sf of project.getSourceFiles()) { + const e = sf.getEnum(name); + if (e) return e; + } + return undefined; +} + +/** Names of every enum the generator emits (so optionals can nullable-ify value-type enums). */ +const ALL_ENUM_NAMES = new Set(); + +function extractProps(iface: InterfaceDeclaration, project: Project): CsProp[] { + const allProps = getAllProperties(iface, project); + const seen = new Set(); + const result: CsProp[] = []; + + for (const p of allProps) { + const tsName = p.getName(); + if (seen.has(tsName)) continue; + seen.add(tsName); + + const tsType = getPropertyType(p); + + const enumMember = tsType.match(/^(\w+)\.(\w+)$/); + const stringLiteral = tsType.match(/^'([^']+)'$/); + let isLiteralDiscriminant = false; + let literalValue: string | undefined; + + const tsPropLower = tsName.toLowerCase(); + if (['type', 'kind', 'status', 'state'].includes(tsPropLower)) { + if (enumMember) { + const enumName = enumMember[1]; + const memberName = enumMember[2]; + const enumDecl = findEnum(project, enumName); + if (enumDecl) { + const mem = enumDecl.getMembers().find((m) => m.getName() === memberName); + if (mem) { + isLiteralDiscriminant = true; + literalValue = String(mem.getValue()); + } + } + } else if (stringLiteral) { + isLiteralDiscriminant = true; + literalValue = stringLiteral[1]; + } else if (/^\w+\.\w+(\s*\|\s*\w+\.\w+)+$/.test(tsType)) { + isLiteralDiscriminant = true; + } + } + + const csName = csIdent(toPascalCase(tsName)); + const hasUnionUndefined = /\|\s*undefined/.test(tsType); + const hasUnionNull = /\|\s*null/.test(tsType); + const hasQuestionToken = p.hasQuestionToken(); + + let csType = mapType(tsType); + if (csType === 'long' && hasFormatFloat(p)) { + csType = 'double'; + } + + const optional = hasQuestionToken || hasUnionUndefined || hasUnionNull; + + result.push({ + csName, + wireName: tsName, + csType, + optional, + doc: getPropertyDoc(p), + isLiteralDiscriminant, + literalValue, + }); + } + return result; +} + +/** True when the C# type is a value type (primitive / JsonElement / known enum). */ +function csIsValueType(csType: string): boolean { + if (csType === 'long' || csType === 'double' || csType === 'bool' || csType === 'JsonElement') { + return true; + } + return ALL_ENUM_NAMES.has(csType); +} + +// ─── Doc Comments ──────────────────────────────────────────────────────────── + +function emitDocComment(prefix: string, doc: string | undefined, lines: string[]): void { + if (!doc) return; + const docLines = doc.split('\n').map((l) => l.trimEnd()); + // Trim trailing blank lines. + while (docLines.length && docLines[docLines.length - 1] === '') docLines.pop(); + if (docLines.length === 0) return; + lines.push(`${prefix}/// `); + for (const line of docLines) { + lines.push(`${prefix}/// ${escapeXmlDoc(line)}`.trimEnd()); + } + lines.push(`${prefix}/// `); +} + +function escapeXmlDoc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// ─── Enum Generation ───────────────────────────────────────────────────────── + +/** + * String enum: + * [JsonConverter(typeof(WireEnumConverter))] + * public enum PolicyState { [WireValue("enabled")] Enabled, ... } + */ +function generateStringEnum(enumDecl: EnumDeclaration): string { + const name = enumDecl.getName(); + const lines: string[] = []; + emitDocComment('', enumDecl.getJsDocs()[0]?.getDescription().trim(), lines); + lines.push(`[JsonConverter(typeof(WireEnumConverter<${name}>))]`); + lines.push(`public enum ${name}`); + lines.push('{'); + const members = enumDecl.getMembers(); + members.forEach((mem) => { + const memberDoc = mem.getJsDocs()[0]?.getDescription().trim(); + emitDocComment(' ', memberDoc, lines); + const wire = String(mem.getValue()); + lines.push(` [WireValue(${JSON.stringify(wire)})]`); + lines.push(` ${mem.getName()},`); + }); + lines.push('}'); + return lines.join('\n'); +} + +/** + * Bitset enum (numeric values): [Flags] enum : uint. System.Text.Json + * serializes enums as their numeric value by default, so unknown future + * bits round-trip losslessly. + */ +function generateBitsetEnum(enumDecl: EnumDeclaration): string { + const name = enumDecl.getName(); + const lines: string[] = []; + emitDocComment('', enumDecl.getJsDocs()[0]?.getDescription().trim(), lines); + lines.push('[Flags]'); + lines.push(`public enum ${name} : uint`); + lines.push('{'); + for (const mem of enumDecl.getMembers()) { + const memberDoc = mem.getJsDocs()[0]?.getDescription().trim(); + emitDocComment(' ', memberDoc, lines); + lines.push(` ${mem.getName()} = ${mem.getValue()},`); + } + lines.push('}'); + return lines.join('\n'); +} + +function generateEnum(enumDecl: EnumDeclaration): string { + const values = enumDecl.getMembers().map((m) => m.getValue()); + const isNumeric = values.every((v) => typeof v === 'number'); + return isNumeric ? generateBitsetEnum(enumDecl) : generateStringEnum(enumDecl); +} + +// ─── Struct Generation ─────────────────────────────────────────────────────── + +interface StructOpts { + omitDiscriminants?: boolean; + doc?: string; + includeDiscriminants?: boolean; +} + +/** True when the C# type is a generic collection the generator emits + * (`List<...>` / `Dictionary<...>`). A required-but-unset collection is + * modeled as null-forgiving (see {@link csPropDefault}). */ +function csIsCollectionType(csType: string): boolean { + return csType.startsWith('List<') || csType.startsWith('Dictionary<'); +} + +/** + * True when a REQUIRED property is a nested object — i.e. a reference type that + * is neither a string, a StringOrMarkdown wrapper, a value type/enum, nor a + * generic collection. The schema marks these `required`, so the C# field must + * be non-nullable AND enforced on decode. We model that with the C# `required` + * modifier (see {@link csRequiredModifier}) rather than a null-forgiving + * default, so a wire payload omitting the field is rejected — matching the + * schema's `required` array (e.g. SessionAddedParams.summary). Collections keep + * the null-forgiving default because a null collection serializes as `null`, + * which the conformance harness strips (matching Go's nil slice/map). + */ +function csIsRequiredNestedObject(csType: string, optional: boolean): boolean { + if (optional) return false; + if (csIsValueType(csType)) return false; + if (csType === 'string' || csType === 'StringOrMarkdown') return false; + if (csIsCollectionType(csType)) return false; + return true; +} + +/** The `required ` modifier (with trailing space) for properties that must be + * set, else the empty string. */ +function csRequiredModifier(csType: string, optional: boolean): string { + return csIsRequiredNestedObject(csType, optional) ? 'required ' : ''; +} + +function csPropDefault(csType: string, optional: boolean): string { + if (optional) return ''; + // Required value types get the C# default (matches Go's numeric/bool zero). + if (csIsValueType(csType)) return ''; + // Required string → "" (matches Go's string zero value, which serializes + // as "" rather than null). + if (csType === 'string') return ' = "";'; + // Required StringOrMarkdown → empty wrapper, which serializes as "" (matches + // Go's value-type zero). + if (csType === 'StringOrMarkdown') return ' = new();'; + // Required nested objects use the `required` modifier (no default) so a wire + // payload missing the field is rejected, matching the schema's `required` + // array. csRequiredModifier emits the keyword; no initializer is needed. + if (csIsRequiredNestedObject(csType, optional)) return ''; + // Required collections → null-forgiving. A null collection serializes as + // `null`, which the conformance harness strips — matching Go, where a + // required-but-unset slice/map is `nil` → `null`. Deserialization and + // reducers populate these before they are read. + return ' = null!;'; +} + +function generateCsClass(csName: string, props: CsProp[], opts: StructOpts = {}): string { + const lines: string[] = []; + emitDocComment('', opts.doc, lines); + const include = opts.includeDiscriminants === true; + const emittedProps = props.filter( + (p) => include || !(opts.omitDiscriminants && p.isLiteralDiscriminant), + ); + + lines.push(`public sealed class ${csName}`); + lines.push('{'); + emittedProps.forEach((p, idx) => { + if (idx > 0) lines.push(''); + if (p.doc) emitDocComment(' ', p.doc, lines); + lines.push(` [JsonPropertyName(${JSON.stringify(p.wireName)})]`); + let csType = p.csType; + if (p.optional) { + lines.push(' [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]'); + csType = `${csType}?`; + } + const def = csPropDefault(p.csType, p.optional); + const req = csRequiredModifier(p.csType, p.optional); + lines.push(` public ${req}${csType} ${p.csName} { get; set; }${def}`); + }); + lines.push('}'); + return lines.join('\n'); +} + +function generateClassFromInterface( + project: Project, + tsInterfaceName: string, + csNameOverride?: string, + opts: StructOpts = {}, +): string { + const iface = findInterface(project, tsInterfaceName); + if (!iface) throw new Error(`Interface ${tsInterfaceName} not found`); + const name = csNameOverride ?? stripIPrefix(tsInterfaceName); + const props = extractProps(iface, project); + const ifaceDoc = iface.getJsDocs()[0]?.getDescription().trim(); + return generateCsClass(name, props, { doc: ifaceDoc, ...opts }); +} + +function generatePartialClass(project: Project, tsInterfaceName: string): string { + const iface = findInterface(project, tsInterfaceName); + if (!iface) throw new Error(`Interface ${tsInterfaceName} not found`); + const props = extractProps(iface, project).map((p) => ({ ...p, optional: true })); + return generateCsClass(partialCsName(tsInterfaceName), props, { + doc: `Partial equivalent of ${stripIPrefix(tsInterfaceName)} — every field is optional for delta updates.`, + }); +} + +// ─── Discriminated Union Generation ────────────────────────────────────────── + +interface UnionVariant { + variantName: string; + innerType: string; + wireValue: string; + doc?: string; +} + +interface UnionConfig { + name: string; + discriminantField: string; + doc?: string; + variants: UnionVariant[]; + unknown?: boolean; +} + +function generateDiscriminatedUnion(cfg: UnionConfig): string { + const lines: string[] = []; + emitDocComment('', cfg.doc, lines); + lines.push(`[JsonConverter(typeof(${cfg.name}Converter))]`); + lines.push(`public sealed class ${cfg.name} : AhpUnion`); + lines.push('{'); + lines.push(` /// Creates an empty ${cfg.name} (no active variant).`); + lines.push(` public ${cfg.name}() { }`); + lines.push(''); + lines.push(` /// Creates a ${cfg.name} wrapping the given variant value.`); + lines.push(` public ${cfg.name}(object? value) : base(value) { }`); + lines.push('}'); + lines.push(''); + + // Converter + const entries = cfg.variants + .map((v) => ` [${JSON.stringify(v.wireValue)}] = typeof(${v.innerType}),`) + .join('\n'); + lines.push( + `/// System.Text.Json converter for the ${cfg.name} discriminated union.`, + ); + lines.push(`internal sealed class ${cfg.name}Converter : UnionConverter<${cfg.name}>`); + lines.push('{'); + lines.push(` public ${cfg.name}Converter()`); + lines.push(` : base(`); + lines.push(` discriminator: ${JSON.stringify(cfg.discriminantField)},`); + lines.push(` variants: new Dictionary`); + lines.push(' {'); + lines.push(entries); + lines.push(' },'); + lines.push(` allowUnknown: ${cfg.unknown ? 'true' : 'false'})`); + lines.push(' {'); + lines.push(' }'); + lines.push('}'); + return lines.join('\n'); +} + +// ─── State File Generator ──────────────────────────────────────────────────── + +const STATE_ENUMS = [ + 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', + 'SessionInputAnswerState', 'SessionInputAnswerValueKind', 'SessionInputQuestionKind', + 'SessionInputResponseKind', + 'TurnState', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'ToolCallConfirmationReason', 'ToolCallCancellationReason', + 'ConfirmationOptionKind', + 'ToolCallContributorKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'McpServerStatus', 'McpAuthRequiredReason', + 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', +]; + +const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: string }[] = [ + { name: 'Icon' }, + { name: 'ProtectedResourceMetadata' }, + { name: 'RootState' }, + { name: 'RootConfigState' }, + { name: 'AgentInfo' }, + { name: 'SessionModelInfo' }, + { name: 'ModelSelection' }, + { name: 'AgentSelection' }, + { name: 'ConfigPropertySchema' }, + { name: 'ConfigSchema' }, + { name: 'PendingMessage' }, + { name: 'SessionState' }, + { name: 'SessionActiveClient' }, + { name: 'SessionSummary' }, + { name: 'ChangesSummary' }, + { name: 'ProjectInfo' }, + { name: 'SessionConfigPropertySchema' }, + { name: 'SessionConfigSchema' }, + { name: 'SessionConfigState' }, + { name: 'Turn' }, + { name: 'ActiveTurn' }, + { name: 'Message' }, + { name: 'SessionInputOption' }, + { name: 'SessionInputTextAnswerValue' }, + { name: 'SessionInputNumberAnswerValue' }, + { name: 'SessionInputBooleanAnswerValue' }, + { name: 'SessionInputSelectedAnswerValue' }, + { name: 'SessionInputSelectedManyAnswerValue' }, + { name: 'SessionInputAnswered' }, + { name: 'SessionInputSkipped' }, + { name: 'SessionInputTextQuestion' }, + { name: 'SessionInputNumberQuestion' }, + { name: 'SessionInputBooleanQuestion' }, + { name: 'SessionInputSingleSelectQuestion' }, + { name: 'SessionInputMultiSelectQuestion' }, + { name: 'SessionInputRequest' }, + { name: 'TextPosition' }, + { name: 'TextRange' }, + { name: 'TextSelection' }, + { name: 'SimpleMessageAttachment' }, + { name: 'MessageEmbeddedResourceAttachment' }, + { name: 'MessageResourceAttachment' }, + { name: 'MessageAnnotationsAttachment' }, + { name: 'MarkdownResponsePart' }, + { name: 'ContentRef' }, + { name: 'ResourceReponsePart', csName: 'ResourceResponsePart' }, + { name: 'ToolCallResponsePart' }, + { name: 'ReasoningResponsePart' }, + { name: 'SystemNotificationResponsePart' }, + { name: 'ToolCallResult' }, + { name: 'ConfirmationOption' }, + { name: 'ToolCallStreamingState' }, + { name: 'ToolCallPendingConfirmationState' }, + { name: 'ToolCallRunningState' }, + { name: 'ToolCallPendingResultConfirmationState' }, + { name: 'ToolCallCompletedState' }, + { name: 'ToolCallCancelledState' }, + { name: 'ToolDefinition' }, + { name: 'ToolAnnotations' }, + { name: 'ToolResultTextContent' }, + { name: 'ToolResultEmbeddedResourceContent' }, + { name: 'ToolResultResourceContent' }, + { name: 'ToolResultFileEditContent' }, + { name: 'ToolResultTerminalContent' }, + { name: 'ToolResultSubagentContent' }, + { name: 'CustomizationLoadingState' }, + { name: 'CustomizationLoadedState' }, + { name: 'CustomizationDegradedState' }, + { name: 'CustomizationErrorState' }, + { name: 'PluginCustomization' }, + { name: 'ClientPluginCustomization' }, + { name: 'DirectoryCustomization' }, + { name: 'AgentCustomization' }, + { name: 'SkillCustomization' }, + { name: 'PromptCustomization' }, + { name: 'RuleCustomization' }, + { name: 'HookCustomization' }, + { name: 'McpServerCustomization' }, + { name: 'McpServerCustomizationApps' }, + { name: 'AhpMcpUiHostCapabilities' }, + { name: 'McpServerStartingState' }, + { name: 'McpServerReadyState' }, + { name: 'McpServerAuthRequiredState' }, + { name: 'McpServerErrorState' }, + { name: 'McpServerStoppedState' }, + { name: 'ToolCallClientContributor' }, + { name: 'ToolCallMcpContributor' }, + { name: 'FileEdit' }, + { name: 'TerminalInfo' }, + { name: 'TerminalClientClaim' }, + { name: 'TerminalSessionClaim' }, + { name: 'TerminalState' }, + { name: 'TerminalUnclassifiedPart' }, + { name: 'TerminalCommandPart' }, + { name: 'UsageInfo' }, + { name: 'ErrorInfo' }, + { name: 'Snapshot' }, + { name: 'Changeset' }, + { name: 'ChangesetState' }, + { name: 'ChangesetFile' }, + { name: 'ChangesetOperation' }, + { name: 'TelemetryCapabilities' }, + { name: 'ResourceWatchState' }, + { name: 'ResourceChange' }, + { name: 'AnnotationsSummary' }, + { name: 'AnnotationsState' }, + { name: 'Annotation' }, + { name: 'AnnotationEntry' }, +]; + +const RESPONSE_PART_UNION: UnionConfig = { + name: 'ResponsePart', + discriminantField: 'kind', + doc: 'ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference).', + variants: [ + { variantName: 'Markdown', innerType: 'MarkdownResponsePart', wireValue: 'markdown' }, + { variantName: 'ContentRef', innerType: 'ResourceResponsePart', wireValue: 'contentRef' }, + { variantName: 'ToolCall', innerType: 'ToolCallResponsePart', wireValue: 'toolCall' }, + { variantName: 'Reasoning', innerType: 'ReasoningResponsePart', wireValue: 'reasoning' }, + { variantName: 'SystemNotification', innerType: 'SystemNotificationResponsePart', wireValue: 'systemNotification' }, + ], + unknown: true, +}; + +const TOOL_CALL_STATE_UNION: UnionConfig = { + name: 'ToolCallState', + discriminantField: 'status', + doc: 'ToolCallState is the full tool call lifecycle state.', + variants: [ + { variantName: 'Streaming', innerType: 'ToolCallStreamingState', wireValue: 'streaming' }, + { variantName: 'PendingConfirmation', innerType: 'ToolCallPendingConfirmationState', wireValue: 'pending-confirmation' }, + { variantName: 'Running', innerType: 'ToolCallRunningState', wireValue: 'running' }, + { variantName: 'PendingResultConfirmation', innerType: 'ToolCallPendingResultConfirmationState', wireValue: 'pending-result-confirmation' }, + { variantName: 'Completed', innerType: 'ToolCallCompletedState', wireValue: 'completed' }, + { variantName: 'Cancelled', innerType: 'ToolCallCancelledState', wireValue: 'cancelled' }, + ], + unknown: true, +}; + +const TERMINAL_CLAIM_UNION: UnionConfig = { + name: 'TerminalClaim', + discriminantField: 'kind', + doc: 'TerminalClaim identifies who currently holds a terminal.', + variants: [ + { variantName: 'Client', innerType: 'TerminalClientClaim', wireValue: 'client' }, + { variantName: 'Session', innerType: 'TerminalSessionClaim', wireValue: 'session' }, + ], + unknown: true, +}; + +const TERMINAL_CONTENT_PART_UNION: UnionConfig = { + name: 'TerminalContentPart', + discriminantField: 'type', + doc: 'TerminalContentPart is a content part within terminal output.', + variants: [ + { variantName: 'Unclassified', innerType: 'TerminalUnclassifiedPart', wireValue: 'unclassified' }, + { variantName: 'Command', innerType: 'TerminalCommandPart', wireValue: 'command' }, + ], + unknown: true, +}; + +const SESSION_INPUT_QUESTION_UNION: UnionConfig = { + name: 'SessionInputQuestion', + discriminantField: 'kind', + doc: 'SessionInputQuestion is one question within a session input request.', + variants: [ + { variantName: 'Text', innerType: 'SessionInputTextQuestion', wireValue: 'text' }, + { variantName: 'Number', innerType: 'SessionInputNumberQuestion', wireValue: 'number' }, + { variantName: 'Integer', innerType: 'SessionInputNumberQuestion', wireValue: 'integer' }, + { variantName: 'Boolean', innerType: 'SessionInputBooleanQuestion', wireValue: 'boolean' }, + { variantName: 'SingleSelect', innerType: 'SessionInputSingleSelectQuestion', wireValue: 'single-select' }, + { variantName: 'MultiSelect', innerType: 'SessionInputMultiSelectQuestion', wireValue: 'multi-select' }, + ], + unknown: true, +}; + +const SESSION_INPUT_ANSWER_VALUE_UNION: UnionConfig = { + name: 'SessionInputAnswerValue', + discriminantField: 'kind', + doc: 'SessionInputAnswerValue is the value captured for one answer.', + variants: [ + { variantName: 'Text', innerType: 'SessionInputTextAnswerValue', wireValue: 'text' }, + { variantName: 'Number', innerType: 'SessionInputNumberAnswerValue', wireValue: 'number' }, + { variantName: 'Boolean', innerType: 'SessionInputBooleanAnswerValue', wireValue: 'boolean' }, + { variantName: 'Selected', innerType: 'SessionInputSelectedAnswerValue', wireValue: 'selected' }, + { variantName: 'SelectedMany', innerType: 'SessionInputSelectedManyAnswerValue', wireValue: 'selected-many' }, + ], + unknown: true, +}; + +const SESSION_INPUT_ANSWER_UNION: UnionConfig = { + name: 'SessionInputAnswer', + discriminantField: 'state', + doc: 'SessionInputAnswer is a draft, submitted, or skipped answer for one question.', + variants: [ + { variantName: 'Draft', innerType: 'SessionInputAnswered', wireValue: 'draft' }, + { variantName: 'Submitted', innerType: 'SessionInputAnswered', wireValue: 'submitted' }, + { variantName: 'Skipped', innerType: 'SessionInputSkipped', wireValue: 'skipped' }, + ], + unknown: true, +}; + +const TOOL_RESULT_CONTENT_UNION: UnionConfig = { + name: 'ToolResultContent', + discriminantField: 'type', + doc: 'ToolResultContent is a content block in a tool result.', + variants: [ + { variantName: 'Text', innerType: 'ToolResultTextContent', wireValue: 'text' }, + { variantName: 'EmbeddedResource', innerType: 'ToolResultEmbeddedResourceContent', wireValue: 'embeddedResource' }, + { variantName: 'Resource', innerType: 'ToolResultResourceContent', wireValue: 'resource' }, + { variantName: 'FileEdit', innerType: 'ToolResultFileEditContent', wireValue: 'fileEdit' }, + { variantName: 'Terminal', innerType: 'ToolResultTerminalContent', wireValue: 'terminal' }, + { variantName: 'Subagent', innerType: 'ToolResultSubagentContent', wireValue: 'subagent' }, + ], + unknown: true, +}; + +const MESSAGE_ATTACHMENT_UNION: UnionConfig = { + name: 'MessageAttachment', + discriminantField: 'type', + doc: 'MessageAttachment is an attachment associated with a Message.', + variants: [ + { variantName: 'Simple', innerType: 'SimpleMessageAttachment', wireValue: 'simple' }, + { variantName: 'EmbeddedResource', innerType: 'MessageEmbeddedResourceAttachment', wireValue: 'embeddedResource' }, + { variantName: 'Resource', innerType: 'MessageResourceAttachment', wireValue: 'resource' }, + { variantName: 'Annotations', innerType: 'MessageAnnotationsAttachment', wireValue: 'annotations' }, + ], + unknown: true, +}; + +const CUSTOMIZATION_UNION: UnionConfig = { + name: 'Customization', + discriminantField: 'type', + doc: 'Customization is a top-level customization (plugin, directory, or MCP server).', + variants: [ + { variantName: 'Plugin', innerType: 'PluginCustomization', wireValue: 'plugin' }, + { variantName: 'Directory', innerType: 'DirectoryCustomization', wireValue: 'directory' }, + { variantName: 'McpServer', innerType: 'McpServerCustomization', wireValue: 'mcpServer' }, + ], + unknown: true, +}; + +const CHILD_CUSTOMIZATION_UNION: UnionConfig = { + name: 'ChildCustomization', + discriminantField: 'type', + doc: 'ChildCustomization is a child customization living inside a plugin or directory.', + variants: [ + { variantName: 'Agent', innerType: 'AgentCustomization', wireValue: 'agent' }, + { variantName: 'Skill', innerType: 'SkillCustomization', wireValue: 'skill' }, + { variantName: 'Prompt', innerType: 'PromptCustomization', wireValue: 'prompt' }, + { variantName: 'Rule', innerType: 'RuleCustomization', wireValue: 'rule' }, + { variantName: 'Hook', innerType: 'HookCustomization', wireValue: 'hook' }, + { variantName: 'McpServer', innerType: 'McpServerCustomization', wireValue: 'mcpServer' }, + ], + unknown: true, +}; + +const CUSTOMIZATION_LOAD_STATE_UNION: UnionConfig = { + name: 'CustomizationLoadState', + discriminantField: 'kind', + doc: 'CustomizationLoadState is the host-reported load state for a container customization.', + variants: [ + { variantName: 'Loading', innerType: 'CustomizationLoadingState', wireValue: 'loading' }, + { variantName: 'Loaded', innerType: 'CustomizationLoadedState', wireValue: 'loaded' }, + { variantName: 'Degraded', innerType: 'CustomizationDegradedState', wireValue: 'degraded' }, + { variantName: 'Error', innerType: 'CustomizationErrorState', wireValue: 'error' }, + ], + unknown: true, +}; + +const MCP_SERVER_STATUS_UNION: UnionConfig = { + name: 'McpServerState', + discriminantField: 'kind', + doc: 'McpServerState is the lifecycle state of an MCP server customization.', + variants: [ + { variantName: 'Starting', innerType: 'McpServerStartingState', wireValue: 'starting' }, + { variantName: 'Ready', innerType: 'McpServerReadyState', wireValue: 'ready' }, + { variantName: 'AuthRequired', innerType: 'McpServerAuthRequiredState', wireValue: 'authRequired' }, + { variantName: 'Error', innerType: 'McpServerErrorState', wireValue: 'error' }, + { variantName: 'Stopped', innerType: 'McpServerStoppedState', wireValue: 'stopped' }, + ], + unknown: true, +}; + +const TOOL_CALL_CONTRIBUTOR_UNION: UnionConfig = { + name: 'ToolCallContributor', + discriminantField: 'kind', + doc: 'ToolCallContributor identifies who provides a tool call (client or MCP server).', + variants: [ + { variantName: 'Client', innerType: 'ToolCallClientContributor', wireValue: 'client' }, + { variantName: 'Mcp', innerType: 'ToolCallMcpContributor', wireValue: 'mcp' }, + ], + unknown: true, +}; + +function generateSnapshotState(): string { + return `/// +/// SnapshotState is the state payload of a snapshot — root, session, +/// terminal, or changeset state. UnmarshalJSON probes for required fields +/// in the canonical order (session → terminal → changeset → root). +/// +[JsonConverter(typeof(SnapshotStateConverter))] +public sealed class SnapshotState +{ + /// Root state variant, when populated. + public RootState? Root { get; set; } + + /// Session state variant, when populated. + public SessionState? Session { get; set; } + + /// Terminal state variant, when populated. + public TerminalState? Terminal { get; set; } + + /// Changeset state variant, when populated. + public ChangesetState? Changeset { get; set; } +} + +/// System.Text.Json converter for the SnapshotState shape-probed union. +internal sealed class SnapshotStateConverter : JsonConverter +{ + public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var result = new SnapshotState(); + if (root.TryGetProperty("summary", out _) && root.TryGetProperty("lifecycle", out _)) + { + result.Session = root.Deserialize(options); + } + else if (root.TryGetProperty("content", out _)) + { + result.Terminal = root.Deserialize(options); + } + else if (root.TryGetProperty("status", out _) && root.TryGetProperty("files", out _)) + { + result.Changeset = root.Deserialize(options); + } + else + { + result.Root = root.Deserialize(options); + } + return result; + } + + public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) + { + if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, options); return; } + if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, options); return; } + if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, options); return; } + if (value.Root is not null) { JsonSerializer.Serialize(writer, value.Root, options); return; } + writer.WriteNullValue(); + } +}`; +} + +function generateStateFile(project: Project): string { + const lines: string[] = [fileHeader()]; + + lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); + for (const enumName of STATE_ENUMS) { + const decl = findEnum(project, enumName); + if (decl) { + lines.push(generateEnum(decl)); + lines.push(''); + } + } + + lines.push('// ─── Classes ──────────────────────────────────────────────────────────\n'); + for (const entry of STATE_STRUCTS) { + try { + lines.push( + generateClassFromInterface(project, entry.name, entry.csName, { + omitDiscriminants: entry.omitDiscriminants, + }), + ); + lines.push(''); + } catch (e) { + lines.push(`// TODO: could not generate ${entry.name}: ${e}`); + lines.push(''); + } + } + + lines.push('// ─── Discriminated Unions ─────────────────────────────────────────────\n'); + for (const u of [ + RESPONSE_PART_UNION, TOOL_CALL_STATE_UNION, TERMINAL_CLAIM_UNION, + TERMINAL_CONTENT_PART_UNION, SESSION_INPUT_QUESTION_UNION, + SESSION_INPUT_ANSWER_VALUE_UNION, SESSION_INPUT_ANSWER_UNION, + TOOL_RESULT_CONTENT_UNION, MESSAGE_ATTACHMENT_UNION, CUSTOMIZATION_UNION, + CHILD_CUSTOMIZATION_UNION, CUSTOMIZATION_LOAD_STATE_UNION, + MCP_SERVER_STATUS_UNION, TOOL_CALL_CONTRIBUTOR_UNION, + ]) { + lines.push(generateDiscriminatedUnion(u)); + lines.push(''); + } + lines.push(generateSnapshotState()); + lines.push(''); + + return lines.join('\n'); +} + +// ─── Actions File Generator ────────────────────────────────────────────────── + +const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string }[] = [ + { type: 'root/agentsChanged', variantName: 'RootAgentsChanged', tsInterface: 'RootAgentsChangedAction' }, + { type: 'root/activeSessionsChanged', variantName: 'RootActiveSessionsChanged', tsInterface: 'RootActiveSessionsChangedAction' }, + { type: 'root/configChanged', variantName: 'RootConfigChanged', tsInterface: 'RootConfigChangedAction' }, + { type: 'session/ready', variantName: 'SessionReady', tsInterface: 'SessionReadyAction' }, + { type: 'session/creationFailed', variantName: 'SessionCreationFailed', tsInterface: 'SessionCreationFailedAction' }, + { type: 'session/turnStarted', variantName: 'SessionTurnStarted', tsInterface: 'SessionTurnStartedAction' }, + { type: 'session/delta', variantName: 'SessionDelta', tsInterface: 'SessionDeltaAction' }, + { type: 'session/responsePart', variantName: 'SessionResponsePart', tsInterface: 'SessionResponsePartAction' }, + { type: 'session/toolCallStart', variantName: 'SessionToolCallStart', tsInterface: 'SessionToolCallStartAction' }, + { type: 'session/toolCallDelta', variantName: 'SessionToolCallDelta', tsInterface: 'SessionToolCallDeltaAction' }, + { type: 'session/toolCallReady', variantName: 'SessionToolCallReady', tsInterface: 'SessionToolCallReadyAction' }, + { type: 'session/toolCallConfirmed', variantName: 'SessionToolCallConfirmed', tsInterface: '_merged_' }, + { type: 'session/toolCallComplete', variantName: 'SessionToolCallComplete', tsInterface: 'SessionToolCallCompleteAction' }, + { type: 'session/toolCallResultConfirmed', variantName: 'SessionToolCallResultConfirmed', tsInterface: 'SessionToolCallResultConfirmedAction' }, + { type: 'session/turnComplete', variantName: 'SessionTurnComplete', tsInterface: 'SessionTurnCompleteAction' }, + { type: 'session/turnCancelled', variantName: 'SessionTurnCancelled', tsInterface: 'SessionTurnCancelledAction' }, + { type: 'session/error', variantName: 'SessionError', tsInterface: 'SessionErrorAction' }, + { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, + { type: 'session/usage', variantName: 'SessionUsage', tsInterface: 'SessionUsageAction' }, + { type: 'session/reasoning', variantName: 'SessionReasoning', tsInterface: 'SessionReasoningAction' }, + { type: 'session/modelChanged', variantName: 'SessionModelChanged', tsInterface: 'SessionModelChangedAction' }, + { type: 'session/agentChanged', variantName: 'SessionAgentChanged', tsInterface: 'SessionAgentChangedAction' }, + { type: 'session/isReadChanged', variantName: 'SessionIsReadChanged', tsInterface: 'SessionIsReadChangedAction' }, + { type: 'session/isArchivedChanged', variantName: 'SessionIsArchivedChanged', tsInterface: 'SessionIsArchivedChangedAction' }, + { type: 'session/activityChanged', variantName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, + { type: 'session/changesetsChanged', variantName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, + { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, + { type: 'session/activeClientChanged', variantName: 'SessionActiveClientChanged', tsInterface: 'SessionActiveClientChangedAction' }, + { type: 'session/activeClientToolsChanged', variantName: 'SessionActiveClientToolsChanged', tsInterface: 'SessionActiveClientToolsChangedAction' }, + { type: 'session/pendingMessageSet', variantName: 'SessionPendingMessageSet', tsInterface: 'SessionPendingMessageSetAction' }, + { type: 'session/pendingMessageRemoved', variantName: 'SessionPendingMessageRemoved', tsInterface: 'SessionPendingMessageRemovedAction' }, + { type: 'session/queuedMessagesReordered', variantName: 'SessionQueuedMessagesReordered', tsInterface: 'SessionQueuedMessagesReorderedAction' }, + { type: 'session/inputRequested', variantName: 'SessionInputRequested', tsInterface: 'SessionInputRequestedAction' }, + { type: 'session/inputAnswerChanged', variantName: 'SessionInputAnswerChanged', tsInterface: 'SessionInputAnswerChangedAction' }, + { type: 'session/inputCompleted', variantName: 'SessionInputCompleted', tsInterface: 'SessionInputCompletedAction' }, + { type: 'session/customizationsChanged', variantName: 'SessionCustomizationsChanged', tsInterface: 'SessionCustomizationsChangedAction' }, + { type: 'session/customizationToggled', variantName: 'SessionCustomizationToggled', tsInterface: 'SessionCustomizationToggledAction' }, + { type: 'session/customizationUpdated', variantName: 'SessionCustomizationUpdated', tsInterface: 'SessionCustomizationUpdatedAction' }, + { type: 'session/customizationRemoved', variantName: 'SessionCustomizationRemoved', tsInterface: 'SessionCustomizationRemovedAction' }, + { type: 'session/mcpServerStateChanged', variantName: 'SessionMcpServerStateChanged', tsInterface: 'SessionMcpServerStateChangedAction' }, + { type: 'session/truncated', variantName: 'SessionTruncated', tsInterface: 'SessionTruncatedAction' }, + { type: 'session/configChanged', variantName: 'SessionConfigChanged', tsInterface: 'SessionConfigChangedAction' }, + { type: 'session/metaChanged', variantName: 'SessionMetaChanged', tsInterface: 'SessionMetaChangedAction' }, + { type: 'session/toolCallContentChanged', variantName: 'SessionToolCallContentChanged', tsInterface: 'SessionToolCallContentChangedAction' }, + { type: 'changeset/statusChanged', variantName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, + { type: 'changeset/fileSet', variantName: 'ChangesetFileSet', tsInterface: 'ChangesetFileSetAction' }, + { type: 'changeset/fileRemoved', variantName: 'ChangesetFileRemoved', tsInterface: 'ChangesetFileRemovedAction' }, + { type: 'changeset/operationsChanged', variantName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, + { type: 'changeset/operationStatusChanged', variantName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, + { type: 'changeset/cleared', variantName: 'ChangesetCleared', tsInterface: 'ChangesetClearedAction' }, + { type: 'root/terminalsChanged', variantName: 'RootTerminalsChanged', tsInterface: 'RootTerminalsChangedAction' }, + { type: 'terminal/data', variantName: 'TerminalData', tsInterface: 'TerminalDataAction' }, + { type: 'terminal/input', variantName: 'TerminalInput', tsInterface: 'TerminalInputAction' }, + { type: 'terminal/resized', variantName: 'TerminalResized', tsInterface: 'TerminalResizedAction' }, + { type: 'terminal/claimed', variantName: 'TerminalClaimed', tsInterface: 'TerminalClaimedAction' }, + { type: 'terminal/titleChanged', variantName: 'TerminalTitleChanged', tsInterface: 'TerminalTitleChangedAction' }, + { type: 'terminal/cwdChanged', variantName: 'TerminalCwdChanged', tsInterface: 'TerminalCwdChangedAction' }, + { type: 'terminal/exited', variantName: 'TerminalExited', tsInterface: 'TerminalExitedAction' }, + { type: 'terminal/cleared', variantName: 'TerminalCleared', tsInterface: 'TerminalClearedAction' }, + { type: 'terminal/commandDetectionAvailable', variantName: 'TerminalCommandDetectionAvailable', tsInterface: 'TerminalCommandDetectionAvailableAction' }, + { type: 'terminal/commandExecuted', variantName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, + { type: 'terminal/commandFinished', variantName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, + { type: 'resourceWatch/changed', variantName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'annotations/set', variantName: 'AnnotationsSet', tsInterface: 'AnnotationsSetAction' }, + { type: 'annotations/removed', variantName: 'AnnotationsRemoved', tsInterface: 'AnnotationsRemovedAction' }, + { type: 'annotations/entrySet', variantName: 'AnnotationsEntrySet', tsInterface: 'AnnotationsEntrySetAction' }, + { type: 'annotations/entryRemoved', variantName: 'AnnotationsEntryRemoved', tsInterface: 'AnnotationsEntryRemovedAction' }, +]; + +function generateMergedToolCallConfirmedClass(): string { + return `/// +/// SessionToolCallConfirmedAction — the client approves or denies a +/// pending tool call (merged approved + denied variants on the wire). +/// +public sealed class SessionToolCallConfirmedAction +{ + [JsonPropertyName("type")] + public ActionType Type { get; set; } + + [JsonPropertyName("turnId")] + public string TurnId { get; set; } = ""; + + [JsonPropertyName("toolCallId")] + public string ToolCallId { get; set; } = ""; + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + [JsonPropertyName("approved")] + public bool Approved { get; set; } + + [JsonPropertyName("confirmed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; set; } + + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallCancellationReason? Reason { get; set; } + + [JsonPropertyName("editedToolInput")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditedToolInput { get; set; } + + [JsonPropertyName("userSuggestion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; set; } + + [JsonPropertyName("reasonMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; set; } + + [JsonPropertyName("selectedOptionId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SelectedOptionId { get; set; } +}`; +} + +function generateActionEnvelope(): string { + return `/// +/// ActionEnvelope wraps every action with the channel URI it belongs to, +/// the server-assigned monotonic sequence number, and an optional origin. +/// +public sealed class ActionEnvelope +{ + [JsonPropertyName("channel")] + public string Channel { get; set; } = ""; + + [JsonPropertyName("action")] + public StateAction Action { get; set; } = new(); + + [JsonPropertyName("serverSeq")] + public long ServerSeq { get; set; } + + [JsonPropertyName("origin")] + public ActionOrigin? Origin { get; set; } + + [JsonPropertyName("rejectionReason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RejectionReason { get; set; } +}`; +} + +function generateActionsUnion(): string { + const cfg: UnionConfig = { + name: 'StateAction', + discriminantField: 'type', + doc: 'StateAction is the discriminated union of every state action.', + variants: ACTION_VARIANTS.map((v) => ({ + variantName: v.variantName, + innerType: v.tsInterface === '_merged_' ? 'SessionToolCallConfirmedAction' : stripIPrefix(v.tsInterface), + wireValue: v.type, + })), + unknown: true, + }; + return generateDiscriminatedUnion(cfg); +} + +function generateActionsFile(project: Project): string { + const lines: string[] = [fileHeader()]; + + lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); + const actionTypeEnum = findEnum(project, 'ActionType'); + lines.push(actionTypeEnum ? generateEnum(actionTypeEnum) : '// TODO: ActionType enum not found'); + lines.push(''); + + lines.push('// ─── Action Envelope ─────────────────────────────────────────────────\n'); + lines.push(generateClassFromInterface(project, 'ActionOrigin')); + lines.push(''); + lines.push(generateActionEnvelope()); + lines.push(''); + + lines.push('// ─── Action Payloads ─────────────────────────────────────────────────\n'); + for (const v of ACTION_VARIANTS) { + if (v.tsInterface === '_merged_') { + lines.push(generateMergedToolCallConfirmedClass()); + lines.push(''); + continue; + } + try { + lines.push(generateClassFromInterface(project, v.tsInterface, undefined, { includeDiscriminants: true })); + lines.push(''); + } catch (e) { + lines.push(`// TODO: could not generate ${v.tsInterface}: ${e}`); + lines.push(''); + } + } + + lines.push('// ─── StateAction Union ───────────────────────────────────────────────\n'); + lines.push(generateActionsUnion()); + lines.push(''); + + return lines.join('\n'); +} + +// ─── Commands File Generator ───────────────────────────────────────────────── + +const COMMAND_ENUMS = ['ReconnectResultType', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; + +const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: string }[] = [ + { name: 'InitializeParams' }, { name: 'InitializeResult' }, + { name: 'ClientCapabilities' }, + { name: 'ReconnectParams' }, + // Union variants MUST self-carry their `type` discriminator: UnionConverter.Write + // serializes the inner value by its runtime type and relies on that property to + // emit the discriminator (matching ACTION_VARIANTS' includeDiscriminants). Omitting + // it silently drops `type` on write, breaking the reconnect-result round-trip. + { name: 'ReconnectReplayResult' }, + { name: 'ReconnectSnapshotResult' }, + { name: 'SubscribeParams' }, { name: 'SubscribeResult' }, + { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, + { name: 'DisposeSessionParams' }, + { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, + { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, + { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, + { name: 'ResourceListParams' }, { name: 'ResourceListResult' }, + { name: 'DirectoryEntry' }, + { name: 'ResourceCopyParams' }, { name: 'ResourceCopyResult' }, + { name: 'ResourceDeleteParams' }, { name: 'ResourceDeleteResult' }, + { name: 'ResourceMoveParams' }, { name: 'ResourceMoveResult' }, + { name: 'ResourceResolveParams' }, { name: 'ResourceResolveResult' }, + { name: 'ResourceMkdirParams' }, { name: 'ResourceMkdirResult' }, + { name: 'ResourceRequestParams' }, { name: 'ResourceRequestResult' }, + { name: 'CreateResourceWatchParams' }, { name: 'CreateResourceWatchResult' }, + { name: 'FetchTurnsParams' }, { name: 'FetchTurnsResult' }, + { name: 'UnsubscribeParams' }, { name: 'DispatchActionParams' }, + { name: 'AuthenticateParams' }, { name: 'AuthenticateResult' }, + { name: 'CreateTerminalParams' }, { name: 'DisposeTerminalParams' }, + { name: 'ResolveSessionConfigParams' }, { name: 'ResolveSessionConfigResult' }, + { name: 'SessionConfigCompletionsParams' }, { name: 'SessionConfigCompletionsResult' }, + { name: 'SessionConfigValueItem' }, + { name: 'CompletionsParams' }, { name: 'CompletionItem' }, { name: 'CompletionsResult' }, + { name: 'InvokeChangesetOperationParams' }, { name: 'InvokeChangesetOperationResult' }, + { name: 'ChangesetOperationFollowUp' }, +]; + +const RECONNECT_RESULT_UNION: UnionConfig = { + name: 'ReconnectResult', + discriminantField: 'type', + doc: 'ReconnectResult is the result of the `reconnect` command.', + variants: [ + { variantName: 'Replay', innerType: 'ReconnectReplayResult', wireValue: 'replay' }, + { variantName: 'Snapshot', innerType: 'ReconnectSnapshotResult', wireValue: 'snapshot' }, + ], +}; + +function generateChangesetOperationTargetCs(): string { + return `/// +/// ChangesetOperationTarget identifies the file or range a +/// ChangesetOperation should act on. +/// +[JsonConverter(typeof(ChangesetOperationTargetConverter))] +public sealed class ChangesetOperationTarget : AhpUnion +{ + public ChangesetOperationTarget() { } + public ChangesetOperationTarget(object? value) : base(value) { } +} + +/// Targets an entire resource. +public sealed class ChangesetOperationResourceTarget +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = "resource"; + + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + [JsonPropertyName("side")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; set; } +} + +/// Targets a range within a resource. +public sealed class ChangesetOperationRangeTarget +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = "range"; + + [JsonPropertyName("resource")] + public string Resource { get; set; } = ""; + + [JsonPropertyName("side")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; set; } + + [JsonPropertyName("range")] + public ChangesetOperationTargetRange Range { get; set; } = new(); +} + +/// The [start, end] index pair for a range target. +public sealed class ChangesetOperationTargetRange +{ + [JsonPropertyName("start")] + public long Start { get; set; } + + [JsonPropertyName("end")] + public long End { get; set; } +} + +/// System.Text.Json converter for the ChangesetOperationTarget union. +internal sealed class ChangesetOperationTargetConverter : UnionConverter +{ + public ChangesetOperationTargetConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["resource"] = typeof(ChangesetOperationResourceTarget), + ["range"] = typeof(ChangesetOperationRangeTarget), + }, + allowUnknown: false) + { + } +}`; +} + +function generateCommandsFile(project: Project): string { + const lines: string[] = [fileHeader()]; + + lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); + for (const enumName of COMMAND_ENUMS) { + const decl = findEnum(project, enumName); + if (decl) { + lines.push(generateEnum(decl)); + lines.push(''); + } + } + + lines.push('// ─── Command Payloads ─────────────────────────────────────────────────\n'); + const generated = new Set(); + for (const entry of COMMAND_STRUCTS) { + if (generated.has(entry.name)) continue; + generated.add(entry.name); + try { + lines.push( + generateClassFromInterface(project, entry.name, entry.csName, { + omitDiscriminants: entry.omitDiscriminants, + }), + ); + lines.push(''); + } catch (e) { + lines.push(`// TODO: could not generate ${entry.name}: ${e}`); + lines.push(''); + } + } + + lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n'); + lines.push(generateDiscriminatedUnion(RECONNECT_RESULT_UNION)); + lines.push(''); + + lines.push('// ─── Changeset Operation Unions ───────────────────────────────────────\n'); + lines.push(generateChangesetOperationTargetCs()); + lines.push(''); + + return lines.join('\n'); +} + +// ─── Notifications File Generator ──────────────────────────────────────────── + +const NOTIFICATION_ENUMS = ['AuthRequiredReason']; + +const NOTIFICATION_STRUCTS = [ + 'SessionAddedParams', + 'SessionRemovedParams', + 'SessionSummaryChangedParams', + 'AuthRequiredParams', + 'OtlpExportLogsParams', + 'OtlpExportTracesParams', + 'OtlpExportMetricsParams', +]; + +function generateNotificationsFile(project: Project): string { + const lines: string[] = [fileHeader()]; + + lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); + for (const enumName of NOTIFICATION_ENUMS) { + const decl = findEnum(project, enumName); + if (decl) { + lines.push(generateEnum(decl)); + lines.push(''); + } + } + + const priorPartials = new Set(requiredPartialStructs); + + lines.push('// ─── Notification Payloads ────────────────────────────────────────────\n'); + for (const tsName of NOTIFICATION_STRUCTS) { + try { + lines.push(generateClassFromInterface(project, tsName, undefined, { omitDiscriminants: true })); + lines.push(''); + } catch (e) { + lines.push(`// TODO: could not generate ${tsName}: ${e}`); + lines.push(''); + } + } + + const newPartials = [...requiredPartialStructs].filter((n) => !priorPartials.has(n)); + if (newPartials.length > 0) { + lines.push('// ─── Partial Summaries ────────────────────────────────────────────────\n'); + for (const tsName of newPartials) { + try { + lines.push(generatePartialClass(project, tsName)); + lines.push(''); + } catch (e) { + lines.push(`// TODO: could not generate Partial<${tsName}>: ${e}`); + lines.push(''); + } + } + } + + return lines.join('\n'); +} + +// ─── Errors File Generator ─────────────────────────────────────────────────── + +function generateErrorsFile(): string { + return `${fileHeader()} +/// Standard JSON-RPC 2.0 error codes. +public static class JsonRpcErrorCodes +{ + /// The request body was invalid JSON. + public const int ParseError = -32700; + + /// The payload was not a valid JSON-RPC request. + public const int InvalidRequest = -32600; + + /// The requested method does not exist on the server. + public const int MethodNotFound = -32601; + + /// The method parameters did not match the declared schema. + public const int InvalidParams = -32602; + + /// An unspecified server failure. + public const int InternalError = -32603; +} + +/// AHP application-specific error codes (above the JSON-RPC reserved range). +public static class AhpErrorCodes +{ + public const int SessionNotFound = -32001; + public const int ProviderNotFound = -32002; + public const int SessionAlreadyExists = -32003; + public const int TurnInProgress = -32004; + public const int UnsupportedProtocolVersion = -32005; + public const int ContentNotFound = -32006; + public const int AuthRequired = -32007; + public const int NotFound = -32008; + public const int PermissionDenied = -32009; + public const int AlreadyExists = -32010; +} + +/// Detail payload of an AuthRequired (-32007) error. +public sealed class AuthRequiredErrorData +{ + [JsonPropertyName("resources")] + public List Resources { get; set; } = new(); +} + +/// Detail payload of a PermissionDenied (-32009) error. +public sealed class PermissionDeniedErrorData +{ + [JsonPropertyName("request")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceRequestParams? Request { get; set; } +} + +/// Detail payload of an UnsupportedProtocolVersion (-32005) error. +public sealed class UnsupportedProtocolVersionErrorData +{ + [JsonPropertyName("supportedVersions")] + public List SupportedVersions { get; set; } = new(); +} +`; +} + +// ─── Messages File Generator ───────────────────────────────────────────────── + +function generateMessagesFile(): string { + return `${fileHeader()} +/// The canonical JSON-RPC version literal ("2.0"). +public static class JsonRpc +{ + /// The sole allowed value of the jsonrpc field. + public const string Version = "2.0"; +} + +/// A JSON-RPC 2.0 request (method + id). +public sealed class JsonRpcRequest +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("params")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Params { get; set; } +} + +/// A JSON-RPC 2.0 success response. +public sealed class JsonRpcSuccessResponse +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("result")] + public JsonElement Result { get; set; } +} + +/// A JSON-RPC 2.0 error response. +public sealed class JsonRpcErrorResponse +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("id")] + public ulong Id { get; set; } + + [JsonPropertyName("error")] + public JsonRpcErrorObject Error { get; set; } = new(); +} + +/// The standard JSON-RPC 2.0 error object. +public sealed class JsonRpcErrorObject +{ + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Data { get; set; } +} + +/// A JSON-RPC 2.0 notification (method, no id). +public sealed class JsonRpcNotification +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("params")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Params { get; set; } +} + +/// +/// A discriminated union over the four JSON-RPC message shapes. The active +/// variant is chosen by JSON-RPC 2.0's shape rules: +/// request (id + method), notification (method, no id), +/// success-response (id + result), error-response (id + error). +/// +[JsonConverter(typeof(JsonRpcMessageConverter))] +public sealed class JsonRpcMessage +{ + public JsonRpcRequest? Request { get; set; } + public JsonRpcSuccessResponse? SuccessResponse { get; set; } + public JsonRpcErrorResponse? ErrorResponse { get; set; } + public JsonRpcNotification? Notification { get; set; } +} + +/// System.Text.Json converter for the shape-probed JsonRpcMessage union. +internal sealed class JsonRpcMessageConverter : JsonConverter +{ + public override JsonRpcMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + bool hasMethod = root.TryGetProperty("method", out _); + bool hasId = root.TryGetProperty("id", out _); + bool hasResult = root.TryGetProperty("result", out _); + bool hasError = root.TryGetProperty("error", out _); + var msg = new JsonRpcMessage(); + if (hasMethod && hasId) + { + msg.Request = root.Deserialize(options); + } + else if (hasMethod) + { + msg.Notification = root.Deserialize(options); + } + else if (hasError) + { + msg.ErrorResponse = root.Deserialize(options); + } + else if (hasResult) + { + msg.SuccessResponse = root.Deserialize(options); + } + else + { + throw new JsonException("JSON-RPC message has no method/result/error"); + } + return msg; + } + + public override void Write(Utf8JsonWriter writer, JsonRpcMessage value, JsonSerializerOptions options) + { + if (value.Request is not null) { JsonSerializer.Serialize(writer, value.Request, options); return; } + if (value.SuccessResponse is not null) { JsonSerializer.Serialize(writer, value.SuccessResponse, options); return; } + if (value.ErrorResponse is not null) { JsonSerializer.Serialize(writer, value.ErrorResponse, options); return; } + if (value.Notification is not null) { JsonSerializer.Serialize(writer, value.Notification, options); return; } + writer.WriteNullValue(); + } +} +`; +} + +// ─── Version File Generator ────────────────────────────────────────────────── + +function generateVersionFile(project: Project): string { + const { current, supported } = readProtocolVersions(project); + const supportedLiteral = supported.map((v) => ` ${JSON.stringify(v)},`).join('\n'); + return `${fileHeader()} +/// Protocol version constants for the Agent Host Protocol. +public static class ProtocolVersion +{ + /// + /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this + /// generated source speaks. + /// + public const string Current = ${JSON.stringify(current)}; + + private static readonly string[] s_supported = + { +${supportedLiteral} + }; + + /// + /// Every protocol version this client is willing to negotiate, ordered + /// most-preferred-first. The first entry always equals . + /// A fresh copy is returned on every call so callers may mutate it freely. + /// + public static IReadOnlyList Supported => (string[])s_supported.Clone(); + + /// The well-known channel URI for the root channel. + public const string RootResourceUri = "ahp-root://"; +} +`; +} + +// ─── Exhaustiveness ───────────────────────────────────────────────────────── + +function collectEnumNames(project: Project): void { + for (const name of [...STATE_ENUMS, ...COMMAND_ENUMS, ...NOTIFICATION_ENUMS, 'ActionType']) { + ALL_ENUM_NAMES.add(name); + } + // ChildCustomizationType is aliased to CustomizationType (already counted). + void project; +} + +function checkExhaustiveness(project: Project): void { + const protocolModules = ['state.ts', 'actions.ts', 'commands.ts', 'notifications.ts', 'errors.ts']; + const imported = new Set(); + for (const baseName of protocolModules) { + for (const sf of findProtocolSourceFiles(project, baseName)) { + for (const decl of sf.getInterfaces()) { + if (decl.isExported()) imported.add(decl.getName()); + } + for (const decl of sf.getTypeAliases()) { + if (decl.isExported()) imported.add(decl.getName()); + } + } + } + + const coveredByLists = new Set([ + ...STATE_STRUCTS.map((s) => s.name), + ...STATE_ENUMS, + ...COMMAND_STRUCTS.map((s) => s.name), + ...COMMAND_ENUMS, + ...NOTIFICATION_STRUCTS, + ...NOTIFICATION_ENUMS, + ...ACTION_VARIANTS.filter((v) => v.tsInterface !== '_merged_').map((v) => v.tsInterface), + ]); + + const knownSpecial = new Set([ + 'URI', 'BaseParams', 'StringOrMarkdown', 'ToolCallState', 'StateAction', + 'ActionEnvelope', 'ActionOrigin', 'ResponsePart', 'ToolResultContent', + 'SessionToolCallApprovedAction', 'SessionToolCallDeniedAction', + 'SessionToolCallConfirmedAction', 'PingParams', 'TerminalClaim', + 'TerminalContentPart', 'SessionInputQuestion', 'SessionInputAnswerValue', + 'SessionInputAnswer', 'MessageAttachment', 'MessageAttachmentBase', + 'Customization', 'ChildCustomization', 'ChildCustomizationType', + 'CustomizationLoadState', 'McpServerState', 'ToolCallContributor', + 'ReconnectResult', 'AuthRequiredErrorData', + 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', + 'AhpError', 'AhpErrorDetailsMap', 'AhpErrorCode', 'AhpErrorCodeWithData', + 'JsonRpcErrorCode', 'ChangesetOperationTarget', + ]); + + const missing = [...imported].filter((n) => !coveredByLists.has(n) && !knownSpecial.has(n)); + if (missing.length > 0) { + console.warn( + 'generate-csharp.ts exhaustiveness: the following types are exported from ' + + 'the protocol source modules but not covered by the C# generator:\n' + + missing.map((n) => ` - ${n}`).join('\n'), + ); + } +} + +// ─── Main Entry Point ──────────────────────────────────────────────────────── + +export function generateCSharpPackage(project: Project, outputDir: string): void { + collectEnumNames(project); + checkExhaustiveness(project); + + const srcDir = path.join(outputDir, 'src', 'AgentHostProtocol.Abstractions', 'Generated'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync(path.join(srcDir, 'State.generated.cs'), generateStateFile(project)); + fs.writeFileSync(path.join(srcDir, 'Actions.generated.cs'), generateActionsFile(project)); + fs.writeFileSync(path.join(srcDir, 'Commands.generated.cs'), generateCommandsFile(project)); + fs.writeFileSync(path.join(srcDir, 'Notifications.generated.cs'), generateNotificationsFile(project)); + fs.writeFileSync(path.join(srcDir, 'Errors.generated.cs'), generateErrorsFile()); + fs.writeFileSync(path.join(srcDir, 'Messages.generated.cs'), generateMessagesFile()); + fs.writeFileSync(path.join(srcDir, 'Version.generated.cs'), generateVersionFile(project)); +} diff --git a/scripts/generate-release-metadata.ts b/scripts/generate-release-metadata.ts index dd77597f..5df21ae5 100644 --- a/scripts/generate-release-metadata.ts +++ b/scripts/generate-release-metadata.ts @@ -34,7 +34,7 @@ import { readProtocolVersions } from './read-protocol-versions.js'; */ export interface ReleaseMetadata { /** Identifier of the per-language artifact, e.g. `"rust"`, `"kotlin"`. */ - readonly client: 'rust' | 'kotlin' | 'swift' | 'typescript' | 'go'; + readonly client: 'rust' | 'kotlin' | 'swift' | 'typescript' | 'go' | 'dotnet'; /** Native package version of the checked-in source. */ readonly packageVersion: string; /** @@ -98,7 +98,16 @@ export function readGoPackageVersion(versionFile: string): string { return trimmed; } -const CLIENTS = ['rust', 'kotlin', 'swift', 'typescript', 'go'] as const; +/** Reads the bare-semver .NET package version from the VERSION file. */ +export function readDotnetPackageVersion(versionFile: string): string { + const trimmed = versionFile.trim(); + if (trimmed.length === 0) { + throw new Error('readDotnetPackageVersion: VERSION file is empty'); + } + return trimmed; +} + +const CLIENTS = ['rust', 'kotlin', 'swift', 'typescript', 'go', 'dotnet'] as const; interface ClientLocation { readonly metadataPath: string; @@ -148,6 +157,13 @@ function clientLocations(rootDir: string): Record<(typeof CLIENTS)[number], Clie fs.readFileSync(path.join(root, 'clients', 'go', 'VERSION'), 'utf-8'), ), }, + dotnet: { + metadataPath: path.join(rootDir, 'clients', 'dotnet', 'release-metadata.json'), + readVersion: (root) => + readDotnetPackageVersion( + fs.readFileSync(path.join(root, 'clients', 'dotnet', 'VERSION'), 'utf-8'), + ), + }, }; } diff --git a/scripts/generate.ts b/scripts/generate.ts index ac03c081..041f6664 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -15,6 +15,7 @@ import { generateRustCrate } from './generate-rust.js'; import { generateKotlinPackage } from './generate-kotlin.js'; import { generateTypeScriptClient } from './generate-typescript.js'; import { generateGoModule } from './generate-go.js'; +import { generateCSharpPackage } from './generate-csharp.js'; import { generateReleaseMetadata } from './generate-release-metadata.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -28,6 +29,7 @@ const RUST_DIR = path.join(ROOT, 'clients', 'rust'); const KOTLIN_DIR = path.join(ROOT, 'clients', 'kotlin'); const TYPESCRIPT_TYPES_DIR = path.join(ROOT, 'clients', 'typescript', 'src', 'types'); const GO_DIR = path.join(ROOT, 'clients', 'go'); +const DOTNET_DIR = path.join(ROOT, 'clients', 'dotnet'); const args = process.argv.slice(2); const docsOnly = args.includes('--docs'); @@ -38,6 +40,7 @@ const rustOnly = args.includes('--rust'); const kotlinOnly = args.includes('--kotlin'); const typescriptOnly = args.includes('--typescript'); const goOnly = args.includes('--go'); +const dotnetOnly = args.includes('--dotnet'); const metadataOnly = args.includes('--metadata'); const generateAll = !docsOnly && @@ -48,6 +51,7 @@ const generateAll = !kotlinOnly && !typescriptOnly && !goOnly && + !dotnetOnly && !metadataOnly; // Load the TypeScript project @@ -110,6 +114,12 @@ if (generateAll || goOnly) { console.log(` → Go module written to ${path.relative(ROOT, GO_DIR)}/`); } +if (generateAll || dotnetOnly) { + console.log('Generating .NET package...'); + generateCSharpPackage(project, DOTNET_DIR); + console.log(` → .NET sources written to ${path.relative(ROOT, DOTNET_DIR)}/`); +} + if (generateAll || metadataOnly) { console.log('Generating release metadata...'); generateReleaseMetadata(project, ROOT); diff --git a/scripts/verify-changelog.ts b/scripts/verify-changelog.ts index a3867d1c..b1154448 100644 --- a/scripts/verify-changelog.ts +++ b/scripts/verify-changelog.ts @@ -35,6 +35,7 @@ import { readSwiftPackageVersion, readTypeScriptPackageVersion, readGoPackageVersion, + readDotnetPackageVersion, } from './generate-release-metadata.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -157,6 +158,16 @@ function main(): void { 'Bumped clients/go/VERSION? Add a matching ## [X.Y.Z] heading ' + 'to clients/go/CHANGELOG.md before tagging clients/go/vX.Y.Z.', }, + { + label: 'dotnet', + version: readDotnetPackageVersion( + fs.readFileSync(path.join(ROOT, 'clients', 'dotnet', 'VERSION'), 'utf-8'), + ), + changelogPath: path.join(ROOT, 'clients', 'dotnet', 'CHANGELOG.md'), + hint: + 'Bumped clients/dotnet/VERSION? Add a matching ## [X.Y.Z] heading ' + + 'to clients/dotnet/CHANGELOG.md before tagging dotnet/vX.Y.Z.', + }, ]; const failures: { target: ChangelogTarget; relative: string; expectedVersion: string }[] = []; diff --git a/types/test-cases/round-trips/001-action-envelope-session-title-changed.json b/types/test-cases/round-trips/001-action-envelope-session-title-changed.json new file mode 100644 index 00000000..baefea31 --- /dev/null +++ b/types/test-cases/round-trips/001-action-envelope-session-title-changed.json @@ -0,0 +1,21 @@ +{ + "name": "action-envelope-session-title-changed", + "description": "ActionEnvelope carrying a session/titleChanged action decodes its scalar fields and its discriminated action variant; key fields survive a re-encode round-trip.", + "type": "ActionEnvelope", + "wire": { + "channel": "ahp-session:/s1", + "action": { "type": "session/titleChanged", "title": "Hello" }, + "serverSeq": 7, + "origin": null + }, + "expect": { + "channel": "ahp-session:/s1", + "serverSeq": 7, + "action.type": "session/titleChanged", + "action.title": "Hello" + }, + "expectVariant": { + "action": "SessionTitleChangedAction" + }, + "roundTripStable": true +} diff --git a/types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json b/types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json new file mode 100644 index 00000000..2e69bdc0 --- /dev/null +++ b/types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json @@ -0,0 +1,14 @@ +{ + "name": "state-action-unknown-variant-preserved", + "description": "An unknown StateAction discriminator decodes to a raw JsonElement (not an exception) and re-encodes byte-for-byte verbatim.", + "type": "StateAction", + "wireRaw": "{\"type\":\"future/newAction\",\"foo\":42}", + "expect": { + "type": "future/newAction", + "foo": 42 + }, + "expectVariant": { + "": "JsonElement" + }, + "reencodes": true +} diff --git a/types/test-cases/round-trips/003-customization-unknown-type-preserved.json b/types/test-cases/round-trips/003-customization-unknown-type-preserved.json new file mode 100644 index 00000000..6c8c1179 --- /dev/null +++ b/types/test-cases/round-trips/003-customization-unknown-type-preserved.json @@ -0,0 +1,15 @@ +{ + "name": "customization-unknown-type-preserved", + "description": "The Customization union opts into allowUnknown: an unrecognized `type` decodes to a raw JsonElement, does not throw, and re-encodes verbatim.", + "type": "Customization", + "wireRaw": "{\"type\":\"future/unknownCustomization\",\"path\":\"/x\",\"extra\":7}", + "expect": { + "type": "future/unknownCustomization", + "path": "/x", + "extra": 7 + }, + "expectVariant": { + "": "JsonElement" + }, + "reencodes": true +} diff --git a/types/test-cases/round-trips/004-session-status-bitset-flags.json b/types/test-cases/round-trips/004-session-status-bitset-flags.json new file mode 100644 index 00000000..7861f858 --- /dev/null +++ b/types/test-cases/round-trips/004-session-status-bitset-flags.json @@ -0,0 +1,12 @@ +{ + "name": "session-status-bitset-flags", + "description": "SessionStatus is a numeric bitset on the wire. InProgress(8)|IsArchived(64)=72 decodes, the set bits are observable, and an unset bit (Idle=1) is absent.", + "type": "SessionStatus", + "wireRaw": "72", + "expectBitset": { + "has": ["InProgress", "IsArchived"], + "lacks": ["Idle"], + "numeric": 72 + }, + "reencodes": true +} diff --git a/types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json b/types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json new file mode 100644 index 00000000..14d705ec --- /dev/null +++ b/types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json @@ -0,0 +1,11 @@ +{ + "name": "session-status-unknown-bits-preserved", + "description": "Unknown/forward-compat bits in the SessionStatus bitset survive a round-trip. InProgress(8)|IsArchived(64)|bit31(2147483648)=2147483720.", + "type": "SessionStatus", + "wireRaw": "2147483720", + "expectBitset": { + "has": ["InProgress", "IsArchived"], + "numeric": 2147483720 + }, + "reencodes": true +} diff --git a/types/test-cases/round-trips/006-string-or-markdown-plain.json b/types/test-cases/round-trips/006-string-or-markdown-plain.json new file mode 100644 index 00000000..c62ef971 --- /dev/null +++ b/types/test-cases/round-trips/006-string-or-markdown-plain.json @@ -0,0 +1,10 @@ +{ + "name": "string-or-markdown-plain", + "description": "StringOrMarkdown plain form is a bare JSON string and re-encodes verbatim to the same bare string.", + "type": "StringOrMarkdown", + "wireRaw": "\"hello\"", + "expect": { + "": "hello" + }, + "reencodes": true +} diff --git a/types/test-cases/round-trips/007-string-or-markdown-object.json b/types/test-cases/round-trips/007-string-or-markdown-object.json new file mode 100644 index 00000000..01edd91f --- /dev/null +++ b/types/test-cases/round-trips/007-string-or-markdown-object.json @@ -0,0 +1,10 @@ +{ + "name": "string-or-markdown-object", + "description": "StringOrMarkdown object form carries a `markdown` field and re-encodes verbatim.", + "type": "StringOrMarkdown", + "wireRaw": "{\"markdown\":\"# title\"}", + "expect": { + "markdown": "# title" + }, + "reencodes": true +} diff --git a/types/test-cases/round-trips/008-jsonrpc-request.json b/types/test-cases/round-trips/008-jsonrpc-request.json new file mode 100644 index 00000000..428b46e9 --- /dev/null +++ b/types/test-cases/round-trips/008-jsonrpc-request.json @@ -0,0 +1,7 @@ +{ + "name": "jsonrpc-request", + "description": "A JsonRpcMessage with id+method+params decodes as the request variant.", + "type": "JsonRpcMessage", + "wireRaw": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}", + "expectJsonRpcVariant": "request" +} diff --git a/types/test-cases/round-trips/009-jsonrpc-notification.json b/types/test-cases/round-trips/009-jsonrpc-notification.json new file mode 100644 index 00000000..54fd287e --- /dev/null +++ b/types/test-cases/round-trips/009-jsonrpc-notification.json @@ -0,0 +1,7 @@ +{ + "name": "jsonrpc-notification", + "description": "A JsonRpcMessage with method+params but no id decodes as the notification variant.", + "type": "JsonRpcMessage", + "wireRaw": "{\"jsonrpc\":\"2.0\",\"method\":\"action\",\"params\":{}}", + "expectJsonRpcVariant": "notification" +} diff --git a/types/test-cases/round-trips/010-jsonrpc-success.json b/types/test-cases/round-trips/010-jsonrpc-success.json new file mode 100644 index 00000000..90478f6b --- /dev/null +++ b/types/test-cases/round-trips/010-jsonrpc-success.json @@ -0,0 +1,7 @@ +{ + "name": "jsonrpc-success", + "description": "A JsonRpcMessage with id+result decodes as the success-response variant.", + "type": "JsonRpcMessage", + "wireRaw": "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}", + "expectJsonRpcVariant": "success" +} diff --git a/types/test-cases/round-trips/011-jsonrpc-error.json b/types/test-cases/round-trips/011-jsonrpc-error.json new file mode 100644 index 00000000..8b9182f5 --- /dev/null +++ b/types/test-cases/round-trips/011-jsonrpc-error.json @@ -0,0 +1,7 @@ +{ + "name": "jsonrpc-error", + "description": "A JsonRpcMessage with id+error decodes as the error-response variant.", + "type": "JsonRpcMessage", + "wireRaw": "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"x\"}}", + "expectJsonRpcVariant": "error" +} diff --git a/types/test-cases/round-trips/012-changeset-target-resource.json b/types/test-cases/round-trips/012-changeset-target-resource.json new file mode 100644 index 00000000..4681b9d8 --- /dev/null +++ b/types/test-cases/round-trips/012-changeset-target-resource.json @@ -0,0 +1,13 @@ +{ + "name": "changeset-target-resource", + "description": "ChangesetOperationTarget dispatches on its `kind` discriminator: kind=resource decodes the resource-target variant.", + "type": "ChangesetOperationTarget", + "wireRaw": "{\"kind\":\"resource\",\"resource\":\"file:///a.txt\"}", + "expect": { + "kind": "resource", + "resource": "file:///a.txt" + }, + "expectVariant": { + "": "ChangesetOperationResourceTarget" + } +} diff --git a/types/test-cases/round-trips/013-changeset-target-range.json b/types/test-cases/round-trips/013-changeset-target-range.json new file mode 100644 index 00000000..e7d05371 --- /dev/null +++ b/types/test-cases/round-trips/013-changeset-target-range.json @@ -0,0 +1,16 @@ +{ + "name": "changeset-target-range", + "description": "ChangesetOperationTarget with kind=range decodes the range-target variant including its nested start/end range; the discriminator and range survive a re-encode round-trip.", + "type": "ChangesetOperationTarget", + "wireRaw": "{\"kind\":\"range\",\"resource\":\"file:///a.txt\",\"range\":{\"start\":2,\"end\":5}}", + "expect": { + "kind": "range", + "resource": "file:///a.txt", + "range.start": 2, + "range.end": 5 + }, + "expectVariant": { + "": "ChangesetOperationRangeTarget" + }, + "roundTripStable": true +} diff --git a/types/test-cases/round-trips/014-session-input-question-number.json b/types/test-cases/round-trips/014-session-input-question-number.json new file mode 100644 index 00000000..4ce4aa7e --- /dev/null +++ b/types/test-cases/round-trips/014-session-input-question-number.json @@ -0,0 +1,15 @@ +{ + "name": "session-input-question-number", + "description": "SessionInputQuestion kind=number decodes the number-question variant; the typed Kind preserves `number` and min/max decode.", + "type": "SessionInputQuestion", + "wireRaw": "{\"kind\":\"number\",\"id\":\"q1\",\"message\":\"How many?\",\"min\":0,\"max\":10}", + "expect": { + "kind": "number", + "id": "q1", + "min": 0, + "max": 10 + }, + "expectVariant": { + "": "SessionInputNumberQuestion" + } +} diff --git a/types/test-cases/round-trips/015-session-input-question-integer.json b/types/test-cases/round-trips/015-session-input-question-integer.json new file mode 100644 index 00000000..7d44f2fe --- /dev/null +++ b/types/test-cases/round-trips/015-session-input-question-integer.json @@ -0,0 +1,14 @@ +{ + "name": "session-input-question-integer", + "description": "SessionInputQuestion kind=integer also maps to the number-question variant, but the typed Kind preserves `integer` (distinct from `number`); defaultValue decodes.", + "type": "SessionInputQuestion", + "wireRaw": "{\"kind\":\"integer\",\"id\":\"q2\",\"message\":\"How many whole?\",\"defaultValue\":3}", + "expect": { + "kind": "integer", + "id": "q2", + "defaultValue": 3 + }, + "expectVariant": { + "": "SessionInputNumberQuestion" + } +} diff --git a/types/test-cases/round-trips/016-long-above-int32-max-preserved.json b/types/test-cases/round-trips/016-long-above-int32-max-preserved.json new file mode 100644 index 00000000..8f4ae10c --- /dev/null +++ b/types/test-cases/round-trips/016-long-above-int32-max-preserved.json @@ -0,0 +1,18 @@ +{ + "name": "long-above-int32-max-preserved", + "description": "ActionEnvelope.serverSeq is a 64-bit integer; a value above Int32.MaxValue (2147483647) round-trips without 32-bit truncation. Here serverSeq = Int32.MaxValue + 1234567 = 2148131814.", + "type": "ActionEnvelope", + "wire": { + "channel": "ahp-session:/s1", + "action": { "type": "session/titleChanged", "title": "x" }, + "serverSeq": 2148131814, + "origin": null + }, + "expect": { + "serverSeq": 2148131814 + }, + "expectNumberAbove": { + "serverSeq": 2147483647 + }, + "roundTripStable": true +} diff --git a/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json b/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json new file mode 100644 index 00000000..1ba4b061 --- /dev/null +++ b/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json @@ -0,0 +1,23 @@ +{ + "name": "unknown-wire-keys-ignored", + "description": "A known type (SessionSummary) carrying extra, unrecognized JSON keys decodes its known fields and silently drops the unknown ones.", + "type": "SessionSummary", + "wire": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "Hello", + "status": 0, + "createdAt": 1, + "modifiedAt": 2, + "unknownFutureKey": { "nested": true }, + "anotherUnknown": 42 + }, + "expect": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "Hello", + "createdAt": 1, + "modifiedAt": 2 + }, + "expectReencodedAbsent": ["unknownFutureKey", "anotherUnknown"] +} diff --git a/types/test-cases/round-trips/018-nested-optional-null-round-trip.json b/types/test-cases/round-trips/018-nested-optional-null-round-trip.json new file mode 100644 index 00000000..49a92a11 --- /dev/null +++ b/types/test-cases/round-trips/018-nested-optional-null-round-trip.json @@ -0,0 +1,17 @@ +{ + "name": "nested-optional-null-round-trip", + "description": "SessionSummary.project is an optional nested struct. A wire payload omitting it decodes with project null/absent, and a re-encode omits the `project` key entirely (JsonIgnore WhenWritingNull).", + "type": "SessionSummary", + "wire": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "No project", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + }, + "expect": { + "title": "No project" + }, + "expectReencodedAbsent": ["project"] +} diff --git a/types/test-cases/round-trips/019-channel-scoped-notification-uri.json b/types/test-cases/round-trips/019-channel-scoped-notification-uri.json new file mode 100644 index 00000000..9e8613f3 --- /dev/null +++ b/types/test-cases/round-trips/019-channel-scoped-notification-uri.json @@ -0,0 +1,27 @@ +{ + "name": "channel-scoped-notification-uri", + "description": "SessionAddedParams is a channel-scoped notification payload carrying the root `channel` URI plus the REQUIRED `summary` of the new session (the schema marks both `channel` and `summary` required). The channel URI and the key summary fields survive a re-encode round-trip unchanged. The payload also carries an unknown wire key, which the decoder silently drops on re-encode.", + "type": "SessionAddedParams", + "wire": { + "channel": "ahp:/root", + "summary": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "New session", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + }, + "unknownFutureKey": { "nested": true } + }, + "expect": { + "channel": "ahp:/root", + "summary.resource": "ahp-session:/s1", + "summary.provider": "demo", + "summary.title": "New session", + "summary.createdAt": 1, + "summary.modifiedAt": 2 + }, + "expectReencodedAbsent": ["unknownFutureKey"], + "roundTripStable": true +} diff --git a/types/test-cases/round-trips/020-partial-summary-all-null.json b/types/test-cases/round-trips/020-partial-summary-all-null.json new file mode 100644 index 00000000..8bc43c1d --- /dev/null +++ b/types/test-cases/round-trips/020-partial-summary-all-null.json @@ -0,0 +1,11 @@ +{ + "name": "partial-summary-all-null", + "description": "PartialSessionSummary has every field optional. An empty object decodes with every field null/absent and re-encodes back to an empty object — no exception.", + "type": "PartialSessionSummary", + "wireRaw": "{}", + "expectReencodedAbsent": [ + "resource", "provider", "title", "status", "activity", + "createdAt", "modifiedAt", "project", "model", "agent", "workingDirectory" + ], + "reencodes": true +} diff --git a/types/test-cases/round-trips/021-protocol-version-current-non-empty.json b/types/test-cases/round-trips/021-protocol-version-current-non-empty.json new file mode 100644 index 00000000..6cb61ae9 --- /dev/null +++ b/types/test-cases/round-trips/021-protocol-version-current-non-empty.json @@ -0,0 +1,8 @@ +{ + "name": "protocol-version-current-non-empty", + "description": "ProtocolVersion.Current is a non-empty version string.", + "type": "ProtocolVersion", + "expectConstant": { + "current": "non-empty" + } +} diff --git a/types/test-cases/round-trips/022-protocol-version-supported-non-empty.json b/types/test-cases/round-trips/022-protocol-version-supported-non-empty.json new file mode 100644 index 00000000..4ce6beb9 --- /dev/null +++ b/types/test-cases/round-trips/022-protocol-version-supported-non-empty.json @@ -0,0 +1,8 @@ +{ + "name": "protocol-version-supported-non-empty", + "description": "ProtocolVersion.Supported is a non-empty list of supported version strings.", + "type": "ProtocolVersion", + "expectConstant": { + "supported": "non-empty-list" + } +} diff --git a/types/test-cases/round-trips/023-protocol-version-first-supported-is-current.json b/types/test-cases/round-trips/023-protocol-version-first-supported-is-current.json new file mode 100644 index 00000000..bbb60734 --- /dev/null +++ b/types/test-cases/round-trips/023-protocol-version-first-supported-is-current.json @@ -0,0 +1,8 @@ +{ + "name": "protocol-version-first-supported-is-current", + "description": "The first entry of ProtocolVersion.Supported equals ProtocolVersion.Current.", + "type": "ProtocolVersion", + "expectConstant": { + "firstSupportedEqualsCurrent": true + } +} diff --git a/types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json b/types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json new file mode 100644 index 00000000..834bae2c --- /dev/null +++ b/types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json @@ -0,0 +1,25 @@ +{ + "name": "changeset-changekind-known-and-unknown", + "description": "A session/changesetsChanged action carrying two Changeset catalogue entries — one with a recognized changeKind ('session') and one with an UNKNOWN changeKind ('future-kind-xyz') — decodes and re-encodes byte-for-byte. changeKind is an open string field (clients fall back to a default for unrecognized values rather than dropping them), so the unknown variant survives the round-trip unchanged.", + "type": "StateAction", + "wire": { + "type": "session/changesetsChanged", + "changesets": [ + { + "label": "Session Changes", + "uriTemplate": "copilot:/test-session/changeset/session", + "changeKind": "session" + }, + { + "label": "Future Slice", + "uriTemplate": "copilot:/test-session/changeset/future", + "changeKind": "future-kind-xyz" + } + ] + }, + "expect": { + "type": "session/changesetsChanged" + }, + "reencodes": true, + "roundTripStable": true +} diff --git a/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md new file mode 100644 index 00000000..641e8d51 --- /dev/null +++ b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md @@ -0,0 +1,12 @@ +> **STATUS — RESOLVED (2026-06-05).** Every gap below was FOUND by wiring this shared round-trip corpus into all six clients, and FIXED at the source: +> - **Swift (4 bugs)** — unknown `StateAction` empty-encode; `Customization` throws on unknown; `ChangesetOperationTarget` drops `kind` (x2): fixed in `scripts/generate-swift.ts` + regenerated (`ffb4a7d`). `swift test` 97/97. +> - **.NET conformance** — `SessionAddedParams.summary` modeled nullable though schema-required: schema-required nested objects now emit C# `required`; fixture 019 repaired to a valid payload (`e9d1a2d`). 315/315 both TFMs. +> - **Rust** — `SessionStatus` bitset was a closed enum that LOST unknown bits: fixed (`2635980`). `cargo test` green. +> - **Kotlin** — `SessionStatus` uint32 backed by a SIGNED Int (truncation): fixed (`8b4beab`). `gradlew check` 240/240. +> - **Go** — clean (corpus round-trips 22/23; 019 the only skip; no Go bug). +> - **TypeScript** — no runtime decoder (compile-time types only); one representational gap (017 unknown-wire-keys) recorded with a drift tripwire. +> +> The corpus surfaced + fixed real fidelity bugs in **4 of 6** reference clients. Historical analysis retained below for the upstream PR narrative. + +--- +