From 2f974153ce108e624333f0bfb3329beede364856 Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Thu, 18 Jun 2026 00:13:35 -0400 Subject: [PATCH] Add a first-party .NET client for AHP First-party .NET client for the Agent Host Protocol: a hand-written JSON-RPC client + multi-host runtime over a pluggable ITransport (WebSocket transport included), generated wire types, and DI integration. Includes OpenTelemetry-native self-instrumentation (a single ActivitySource + Meter named from the generated AhpTelemetryNames holder), the host-* dropped- event stream values from the shared telemetry contract, reconnect-supervisor metrics, an OtelExport example, and recorder-based telemetry tests. --- .github/workflows/ci.yml | 57 + AGENTS.md | 8 +- CONTRIBUTING.md | 9 +- README.md | 3 +- RELEASING.md | 21 + clients/dotnet/.editorconfig | 118 + clients/dotnet/.gitignore | 2 + clients/dotnet/AGENTS.md | 123 + clients/dotnet/AgentHostProtocol.slnx | 14 + clients/dotnet/CHANGELOG.md | 276 + clients/dotnet/Directory.Build.props | 99 + clients/dotnet/README.md | 138 + clients/dotnet/TELEMETRY.md | 57 + clients/dotnet/VERSION | 1 + .../dotnet/codegen/telemetry/registry.test.ts | 103 + clients/dotnet/codegen/telemetry/registry.ts | 141 + clients/dotnet/docs/decisions/reconnect.md | 80 + .../dotnet/docs/decisions/serialization.md | 145 + clients/dotnet/docs/decisions/sync.md | 114 + .../examples/ConnectWs/ConnectWs.csproj | 17 + clients/dotnet/examples/ConnectWs/Program.cs | 73 + .../examples/OtelExport/OtelExport.csproj | 25 + clients/dotnet/examples/OtelExport/Program.cs | 167 + .../dotnet/examples/ReducersDemo/Program.cs | 75 + .../examples/ReducersDemo/ReducersDemo.csproj | 16 + clients/dotnet/global.json | 6 + clients/dotnet/release-metadata.json | 9 + .../AgentHostProtocol.Abstractions.csproj | 39 + .../Generated/Actions.generated.cs | 2147 ++++++++ .../Generated/Commands.generated.cs | 1222 +++++ .../Generated/Errors.generated.cs | 69 + .../Generated/Messages.generated.cs | 135 + .../Generated/Notifications.generated.cs | 247 + .../Generated/State.generated.cs | 4718 +++++++++++++++++ .../Generated/Telemetry.generated.cs | 129 + .../Generated/Version.generated.cs | 35 + .../Json/AhpUnion.cs | 35 + .../Json/IAhpSerializer.cs | 87 + .../Json/StringOrMarkdown.cs | 92 + .../Json/UnionConverter.cs | 122 + .../Json/WireEnum.cs | 85 + .../Transport/IKeepAliveTransport.cs | 31 + .../Transport/ITransport.cs | 71 + .../Transport/TransportClosedException.cs | 29 + .../AgentHostProtocol.WebSockets.csproj | 37 + .../WebSocketTransport.cs | 292 + .../AgentHostProtocol.csproj | 45 + .../dotnet/src/AgentHostProtocol/AhpClient.cs | 1255 +++++ .../src/AgentHostProtocol/AssemblyInfo.cs | 17 + .../AhpServiceCollectionExtensions.cs | 82 + .../DependencyInjection/IAhpClientFactory.cs | 35 + .../dotnet/src/AgentHostProtocol/Errors.cs | 77 + .../Hosts/FileClientIdStore.cs | 182 + .../Hosts/HostClientHandle.cs | 83 + .../src/AgentHostProtocol/Hosts/HostConfig.cs | 39 + .../src/AgentHostProtocol/Hosts/HostError.cs | 97 + .../src/AgentHostProtocol/Hosts/HostHandle.cs | 78 + .../src/AgentHostProtocol/Hosts/HostId.cs | 34 + .../src/AgentHostProtocol/Hosts/HostState.cs | 45 + .../Hosts/HostedResourceKey.cs | 89 + .../AgentHostProtocol/Hosts/HostedViews.cs | 52 + .../Hosts/IMultiHostClient.cs | 88 + .../Hosts/MultiHostClient.cs | 1490 ++++++ .../Hosts/MultiHostStateMirror.cs | 110 + .../Hosts/MultiHostSupport.cs | 100 + .../Hosts/ReconnectPolicy.cs | 87 + .../src/AgentHostProtocol/IAhpClient.cs | 81 + .../dotnet/src/AgentHostProtocol/Reducers.cs | 1784 +++++++ .../src/AgentHostProtocol/Subscription.cs | 268 + .../SystemTextJsonAhpSerializer.cs | 124 + .../dotnet/src/AgentHostProtocol/Telemetry.cs | 77 + .../AgentHostProtocol.Tests.csproj | 46 + .../ClientIdStoreTests.cs | 80 + .../AgentHostProtocol.Tests/ClientTests.cs | 1037 ++++ .../CrossImplementationConvergenceTests.cs | 48 + .../DependencyInjectionTests.cs | 57 + .../tests/AgentHostProtocol.Tests/FakeHost.cs | 178 + .../FileClientIdStoreTests.cs | 178 + .../FixRegressionTests.cs | 330 ++ .../FixtureDrivenReducerTests.cs | 207 + .../HostStreamDropTagTests.cs | 175 + .../AgentHostProtocol.Tests/HostsTests.cs | 88 + .../IAhpClientTests.cs | 23 + .../AgentHostProtocol.Tests/JsonCanon.cs | 72 + .../MultiHostClientTests.cs | 2363 +++++++++ .../MultiHostStateMirrorTests.cs | 193 + .../NativeReducerTests.cs | 49 + .../ReconnectPolicyTests.cs | 135 + .../SnapshotStateUnionTests.cs | 138 + .../AgentHostProtocol.Tests/TelemetryTests.cs | 477 ++ .../TransportLifetimeTests.cs | 68 + .../AgentHostProtocol.Tests/TransportTests.cs | 268 + .../TypesRoundTripFixtures.cs | 277 + .../WebSocketTransportTests.cs | 290 + .../independent-host-session-convergence.json | 104 + package.json | 4 +- scripts/generate-csharp.ts | 2747 ++++++++++ scripts/generate-release-metadata.ts | 20 +- scripts/generate.ts | 10 + scripts/read-error-codes.ts | 96 + scripts/read-telemetry.test.ts | 175 + scripts/read-telemetry.ts | 142 + scripts/verify-changelog.ts | 11 + scripts/verify-generated.ts | 132 + tsconfig.json | 2 +- 105 files changed, 28148 insertions(+), 10 deletions(-) create mode 100644 clients/dotnet/.editorconfig create mode 100644 clients/dotnet/.gitignore create mode 100644 clients/dotnet/AGENTS.md create mode 100644 clients/dotnet/AgentHostProtocol.slnx create mode 100644 clients/dotnet/CHANGELOG.md create mode 100644 clients/dotnet/Directory.Build.props create mode 100644 clients/dotnet/README.md create mode 100644 clients/dotnet/TELEMETRY.md create mode 100644 clients/dotnet/VERSION create mode 100644 clients/dotnet/codegen/telemetry/registry.test.ts create mode 100644 clients/dotnet/codegen/telemetry/registry.ts create mode 100644 clients/dotnet/docs/decisions/reconnect.md create mode 100644 clients/dotnet/docs/decisions/serialization.md create mode 100644 clients/dotnet/docs/decisions/sync.md create mode 100644 clients/dotnet/examples/ConnectWs/ConnectWs.csproj create mode 100644 clients/dotnet/examples/ConnectWs/Program.cs create mode 100644 clients/dotnet/examples/OtelExport/OtelExport.csproj create mode 100644 clients/dotnet/examples/OtelExport/Program.cs create mode 100644 clients/dotnet/examples/ReducersDemo/Program.cs create mode 100644 clients/dotnet/examples/ReducersDemo/ReducersDemo.csproj create mode 100644 clients/dotnet/global.json create mode 100644 clients/dotnet/release-metadata.json create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Telemetry.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Json/WireEnum.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/IKeepAliveTransport.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/ITransport.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.Abstractions/Transport/TransportClosedException.cs create mode 100644 clients/dotnet/src/AgentHostProtocol.WebSockets/AgentHostProtocol.WebSockets.csproj create mode 100644 clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj create mode 100644 clients/dotnet/src/AgentHostProtocol/AhpClient.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/DependencyInjection/AhpServiceCollectionExtensions.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/DependencyInjection/IAhpClientFactory.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Errors.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostHandle.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostState.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/HostedViews.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/IMultiHostClient.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostSupport.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Hosts/ReconnectPolicy.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/IAhpClient.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Reducers.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Subscription.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs create mode 100644 clients/dotnet/src/AgentHostProtocol/Telemetry.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/ClientIdStoreTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/CrossImplementationConvergenceTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/DependencyInjectionTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/FakeHost.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/IAhpClientTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/JsonCanon.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/ReconnectPolicyTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/SnapshotStateUnionTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/TransportLifetimeTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/interop/independent-host-session-convergence.json create mode 100644 scripts/generate-csharp.ts create mode 100644 scripts/read-error-codes.ts create mode 100644 scripts/read-telemetry.test.ts create mode 100644 scripts/read-telemetry.ts create mode 100644 scripts/verify-generated.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0521878c..60bbd064 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,3 +255,60 @@ 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 + 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 + + # Whitespace formatting gate — the C# analog of the Go job's gofmt check, + # governed by clients/dotnet/.editorconfig. + - name: Verify .NET formatting + run: dotnet format whitespace --verify-no-changes --no-restore + + - name: Build .NET solution + run: dotnet build --no-restore --configuration Release + + - name: Test .NET solution + run: dotnet test --no-build --configuration Release + + - name: Pack .NET solution + run: dotnet pack --no-build --configuration Release + 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..4a20b64c 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 @@ -53,7 +57,7 @@ see [`docs/specification/versioning.md`](docs/specification/versioning.md). ## Updating CHANGELOGs -This repo ships five independently-versioned artifacts (spec + four clients), +This repo ships seven independently-versioned artifacts (spec + six clients), each with its own `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) 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 @@ -94,4 +98,5 @@ When iterating on the protocol surface in `types/`, see for the project's editorial rules on type changes. For language-specific code-gen conventions, see the `AGENTS.md` file in each -client directory (`clients/kotlin/AGENTS.md`, `clients/swift/AGENTS.md`). +client directory (`clients/go/AGENTS.md`, `clients/kotlin/AGENTS.md`, +`clients/swift/AGENTS.md`, `clients/dotnet/AGENTS.md`). 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/.editorconfig b/clients/dotnet/.editorconfig new file mode 100644 index 00000000..0eedb4b4 --- /dev/null +++ b/clients/dotnet/.editorconfig @@ -0,0 +1,118 @@ +# Formatting + code-style conventions for the .NET client. Whitespace is gated +# in CI by `dotnet format whitespace --verify-no-changes` (the C# analog of the +# Go lane's `gofmt -l` check); the code-style (IDExxxx) and analyzer (CAxxxx) +# rules below run at build time via + +# and are gated by . +root = true + +[*.cs] +indent_style = space +indent_size = 4 +tab_width = 4 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# ── C# code style (mirrors what the hand-written code already does) ────────── + +# File-scoped namespaces (25/25 src files use them; zero block-scoped). The code +# follows this uniformly, so it is enforced (build error via TreatWarningsAsErrors). +csharp_style_namespace_declarations = file_scoped:warning + +# `var` is the house style: heavily used where the type is apparent or named on +# the right, but the code does keep explicit types in some spots, so this is a +# documented preference (suggestion), not an enforced rule that would demand +# reformatting the explicit-type sites. +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Members are referenced unqualified (no `this.` / no type-name qualification). +# The code follows this uniformly, so it is enforced. +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# Brace control-flow bodies. The code mixes braced and single-line forms, so +# this is a documented preference (suggestion) rather than an enforced rule. +csharp_prefer_braces = true:suggestion + +# Modern expression preferences. The pattern-matching / initializer rules below +# the code already follows uniformly (enforced); the expression-bodied and +# simple-using preferences are documented (silent) since usage is mixed. +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +csharp_prefer_simple_using_statement = true:silent + +# ── Code-analysis (CAxxxx) severities ─────────────────────────────────────── + +# Dispose-reliability rules promoted to error: a type owning a disposable field +# must be disposable (CA1001), disposable fields must be disposed (CA2213), and +# objects must be disposed before going out of scope (CA2000). These are the +# bounded high-value rules that catch the undisposed-CTS/semaphore class of leak +# without pulling in the broader AnalysisMode=Recommended set. +dotnet_diagnostic.CA1001.severity = error +dotnet_diagnostic.CA2213.severity = error +dotnet_diagnostic.CA2000.severity = error + +# ── Async / threading (VSTHRDxxxx) severities ─────────────────────────────── + +# The Microsoft.VisualStudio.Threading analyzers stay enabled for their +# high-value rule: VSTHRD002 (synchronously blocking on async via .Result / +# .Wait() / .GetAwaiter().GetResult()) keeps its default error severity — that is +# the genuinely deadlock-prone pattern, and the code is clean of it. +# +# Two rules are method-name / heuristic based and fire only on verified-correct +# patterns here, so they are downgraded with the specific reason each is a false +# positive: +# +# VSTHRD103 (call async methods when in an async method) matches by method NAME on +# (a) MemoryStream.Write — an in-memory buffer copy, not I/O; (b) a fire-and-forget +# CancellationTokenSource.Cancel() releasing a linked-token waiter, where +# CancelAsync's await-the-callbacks semantics are unnecessary; (c) the synchronous +# AhpClient.Connect factory, which does no I/O (the transport is already connected; +# the handshake is the separate async InitializeAsync). None are sync-over-async. +dotnet_diagnostic.VSTHRD103.severity = suggestion +# +# VSTHRD003 (avoid awaiting foreign Tasks) warns of a deadlock that needs +# synchronization-context capture; every await in this library passes +# ConfigureAwait(false), which prevents capture, so the awaited +# TaskCompletionSource / SemaphoreSlim.WaitAsync tasks cannot deadlock. +dotnet_diagnostic.VSTHRD003.severity = suggestion + +# ── Test / example relaxations ──────────────────────────────────────────────── + +# Test method names use Method_Scenario underscores by deliberate convention +# (mirrors the Swift/Go test naming); CA1707 (no underscores in identifiers) +# does not apply to test code. The dispose rules above are a shipping-library +# guard: test bodies legitimately new-up short-lived disposables (transports, +# listeners, clients, stores) that the test runner / GC reclaim, so CA2000 / +# CA1001 / CA2213 are not enforced as errors there. +# CA2016: test infrastructure uses `Task.Run(() => SomeAsync(ct))` for +# fire-and-forget background helpers where the lambda already forwards `ct` to +# the actual async work; passing ct to Task.Run itself would cancel the task +# scheduling, which is wrong here. 54 sites across FakeHost / MultiHostClientTests. +# CA1861: inline `new[] { ... }` literals are idiomatic in single-call assertions +# and the per-call allocation cost in tests is irrelevant. +[tests/**/*.cs] +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA2000.severity = none +dotnet_diagnostic.CA1001.severity = none +dotnet_diagnostic.CA2213.severity = none +dotnet_diagnostic.CA2016.severity = none +dotnet_diagnostic.CA1861.severity = none +# VSTHRD200 (Async naming convention) stays at error for the shipping libraries; +# test helper methods are exempt, like the CA1707 underscore-naming relaxation. +dotnet_diagnostic.VSTHRD200.severity = none + +# Examples are illustrative end-to-end snippets, not shipping code; the dispose +# guard is likewise not enforced there. +[examples/**/*.cs] +dotnet_diagnostic.CA2000.severity = none +dotnet_diagnostic.CA1001.severity = none +dotnet_diagnostic.CA2213.severity = none 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..41a26290 --- /dev/null +++ b/clients/dotnet/AGENTS.md @@ -0,0 +1,123 @@ +# 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 `net8.0`), all green (0 skipped): + +1. **Shared reducer conformance** — `FixtureDrivenReducerTests` replays the 189 + 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]` (`CorpusFixture`) iterates every fixture in the dir. +3. **Native unit tests** — `ClientTests` (full `AhpClient` over an in-memory + `MemTransport`, the port of Go's `client_test.go`), `HostsTests`, + `MultiHostClientTests`, `MultiHostStateMirrorTests`, `NativeReducerTests`, + `ReconnectPolicyTests`, `ClientIdStoreTests`, + `FileClientIdStoreTests`, `TransportTests`, `WebSocketTransportTests`. The + multi-host / host / client fake servers share one declarative loop helper, + `FakeHost`. +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.) + +Cross-language parity is verified by the shared fixture corpora the suite +replays — the 189 reducer fixtures (`types/test-cases/reducers/*.json`) and the +round-trip corpus (`types/test-cases/round-trips/*.json`), both of which every +client runs. (A .NET-only grep-based test-count gate used to live here; it was +retired in favor of relying on the shared corpora, which actually exercise the +behavior rather than counting method names.) + +## 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 client targets `net8.0` only. +- [`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..942c6e2e --- /dev/null +++ b/clients/dotnet/CHANGELOG.md @@ -0,0 +1,276 @@ +# 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 + +- **OpenTelemetry-native self-instrumentation**: the client originates traces + + metrics from a single `System.Diagnostics.ActivitySource` and `Meter` + (`AhpTelemetry.Name == "Microsoft.AgentHostProtocol"`), near zero-cost when + nothing is listening. One `ahp.request {method}` span per JSON-RPC request + (`rpc.system` / `rpc.method` / `ahp.outcome` tags) and the `ahp.client.*` + metric family (messages sent/received, request duration, requests in-flight, + active subscriptions, reconnects, dropped events, malformed frames). The + span / metric / attribute NAMES are codegen'd into `AhpTelemetryNames` from a + client-private registry (`clients/dotnet/codegen/telemetry/registry.ts`) so + they live in one place; the registry is structured for promotion to a shared + cross-client contract if AHP ever specs one. See `TELEMETRY.md`. +- `IMultiHostClient` — an interface extracted from `MultiHostClient` so consumers + can depend on (and mock) the multi-host runtime rather than the concrete sealed + facade. `AddAgentHostProtocol()` now also registers `IMultiHostClient`, + forwarding to the same singleton. +- `AhpServiceCollectionExtensions.TelemetrySourceName` — the instrumentation-scope + name constant to pass to OpenTelemetry's `AddSource(...)` / `AddMeter(...)`, with + the wiring snippet in its XML docs (the library takes no OpenTelemetry + dependency itself). +- `AhpTelemetryNames.StreamHost{Event,Subscription,Resource,Snapshot,Summaries}` + constants for the multi-host per-stream `ahp.stream` drop-tag values, and a + generated `AhpTelemetryNames.*Description` constant per metric (the single + source for each instrument's runtime description). +- A new **`examples/OtelExport`** sample (Shape C): wires the AHP instrumentation + scope into an OpenTelemetry pipeline with a console exporter and drives one + client operation so the request span + metrics print. +- `ChangesetOperationStatus.Disabled` — new value for changeset operations + that are currently unavailable and cannot be invoked (upstream #233). +- `ChangesetOperation.Group` — optional identifier for grouping related + changeset operations together in the UI (upstream #233). +- Full support for the **`ahp-chat:` channel** (upstream #213, multi-chat + sessions): `ChatState` (turns, active turn, steering message, queued + messages, input requests), the `SessionState.Chats` catalog (`ChatSummary`) + + `SessionState.DefaultChat` input-routing hint, the full `Chat*Action` + family (`ChatTurnStarted`, `ChatDelta`, `ChatResponsePart`, and the chat + tool-call / input / messaging actions), the `ChatInputQuestion` / + `ChatInputAnswer` / `ChatInputRequest` types, `ChatOrigin` provenance, and + the `CreateChat` / `DisposeChat` commands; reduced by the new + `Reducers.ApplyToChat`, a faithful port of the canonical TypeScript chat + reducer exercised by all 90 shared `reducer: "chat"` fixtures. Brings the + .NET client to cross-language conformance parity on the chat channel. +- `SessionChatAddedAction`, `SessionChatRemovedAction`, + `SessionChatUpdatedAction`, and `DefaultChatChangedAction` handling for + incremental chat-catalog updates on `SessionState`. +- The per-turn chat actions (`ChatTurnStarted`, `ChatDelta`, + `ChatResponsePart`, `ChatReasoning`, `ChatUsage`, `ChatTurnComplete`, + `ChatTurnCancelled`, `ChatError`) now carry an optional `_meta` property bag + (`Dictionary? Meta`) so agent hosts can stamp portable + per-event metadata on the action stream, mirroring the MCP `_meta` + convention (upstream #240). +- `SessionSummary` and `PartialSessionSummary` now carry an optional `_meta` + property bag (`Dictionary? Meta`) for lightweight + server-defined session-list presentation hints; the protocol does not + interpret the values (upstream #254). +- Error metadata fields from upstream #216. +- `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. +- `IAhpSerializer.SerializeToElement(T)` — serializes a value directly to a + `JsonElement` without the intermediate string + `JsonDocument.Parse`. Custom + `IAhpSerializer` implementations must implement this member. +- `IAhpSerializer.Deserialize(JsonElement)`: deserializes directly from an + already-parsed `JsonElement`, avoiding the `GetRawText()` string + re-parse on + the inbound hot path. Custom `IAhpSerializer` implementations must implement + this member. +- `HostReconnectFailedException` (a `HostException` subclass), surfaced on + `HostState.Error` when a host's reconnection cannot proceed: the transport + dropped while the reconnect policy was disabled, or the attempt budget was + exhausted. +- Trim / AOT support: the three shipping packages declare `IsTrimmable` / + `IsAotCompatible` and annotate the reflection-based serialization entry points + with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]`, so trimmed or + Native-AOT consumers receive accurate analyzer warnings. +- Tracks protocol 0.5.0. New `ChangesetContentChangedAction` + (`changeset/contentChanged`) with its reducer: full-replacement semantics + where `files` always replaces the file list, `operations` replaces the + operation list only when present, and `error` is set when present and cleared + otherwise (parity with the canonical reducer; upstream #159 fixtures). New + `MessageOrigin` type and `MessageKind.Agent` / `MessageKind.Tool` members for + non-user-initiated turns (`Message.Origin` is now the typed `MessageOrigin` + rather than an opaque `JsonElement`; upstream #247). `ConfigPropertySchema` + and `SessionConfigPropertySchema` gain `AdditionalProperties` (upstream #245). +- `SessionModelInfo.MaxOutputTokens` and `SessionModelInfo.MaxPromptTokens` + optional fields for communicating model token limits (upstream). + +### Changed + +- **Multiple active clients per session** (upstream + microsoft/agent-host-protocol#261): `SessionState.ActiveClient` + (`SessionActiveClient?`) becomes `SessionState.ActiveClients` + (`List`, required), so several clients can provide tools + and customizations to one session at once. The two session actions are + replaced accordingly: `SessionActiveClientChangedAction` + (`session/activeClientChanged`) → `SessionActiveClientSetAction` + (`session/activeClientSet`, upsert keyed by `clientId`, no longer nullable), + and `SessionActiveClientToolsChangedAction` + (`session/activeClientToolsChanged`) → `SessionActiveClientRemovedAction` + (`session/activeClientRemoved`, carrying the `clientId` to remove). The + reducer upserts on `activeClientSet` and removes-by-`clientId` (no-op on miss) + on `activeClientRemoved`. +- `ConfigPropertySchema.Enum` and `SessionConfigPropertySchema.Enum` are now + `List?` instead of `List?`, allowing numeric, boolean, + and null enum values (the `JsonPrimitive` widening in `types/common/state.ts`). +- `ModelSelection.Config` values are now `Dictionary?` + instead of `Dictionary?`, allowing numeric, boolean, and null + configuration values carried through as-is. + +- The `MultiHostClient` reconnect supervisor now emits the `ahp.client.reconnects` + counter — tagged `ahp.outcome=ok` on a successful reconnect and `ahp.outcome=error` + on each failed attempt and on attempt-budget exhaustion — so multi-host reconnects + are observable, matching the single-host client's instrumentation. +- The multi-host per-stream drop tags and the metric instrument descriptions now + reference the generated `AhpTelemetryNames` constants instead of hand-copied + literals, so they cannot drift from the generated contract. The drop tags are + also cached (one allocation per stream kind, not per evicted event). +- Per-turn / tool-call / input / messaging reducer logic moved from + `SessionState` to `ChatState`, matching upstream #213's split of the session + turn surface into the per-chat channel. +- `NowIso()` now emits ISO 8601 UTC with a `Z` suffix (was the C# round-trip + format ending `+00:00`), matching the cross-client wire timestamp format. +- Generated write-once wire payloads (every `*Action` / `*Command` / + `*Notification` and value object) are now `sealed record` types with + `init`-only properties; the state types the reducers mutate in place + (`SessionState`, `RootState`, `TerminalState`, `SessionSummary`, … ) remain + mutable `sealed class`. Named-initializer construction is unchanged. +- Required non-nullable wire fields now use the C# `required` modifier instead + of fabricated `""` / `null!` defaults. A payload that omits a required field + is rejected on deserialize (matching the schema's `required` array) rather + than silently materializing an empty value. +- The tool-call lifecycle reducers (`delta`, `ready`, `confirmed`, `complete`, + `resultConfirmed`, `contentChanged`) now propagate the action's `_meta` onto + the resulting tool-call state, so provider metadata stays synchronized as a + tool call advances beyond its initial `start` event (parity with the + canonical reducer; upstream #211). +- `AhpClient.RequestAsync` now returns `Task` (was + `Task`), making the empty-result case explicit for callers. The typed + protocol methods (`InitializeAsync`, `ReconnectAsync`, the subscribe helpers) + throw `AhpRpcException` when the server returns no result rather than handing + back a null. +- Failures surfaced on `AhpClient.Error` and `HostState.Error` are now typed + (`AhpTransportException`, `HostReconnectFailedException`) instead of a bare + `System.Exception`, so callers can pattern-match them. +- `HostConfig.Id` and `HostHandle.Id` now use the C# `required` modifier. +- Reduced per-message allocations on the hottest paths: inbound notifications and + request results deserialize straight from the parsed `JsonElement` (no string + round-trip), the notification fan-out skips its snapshot allocations when there + are no subscribers, and the WebSocket receive loop decodes single-frame + messages without a `MemoryStream`. + +### Fixed + +- `ChangesetOperationRangeTarget.Range` now serializes as the canonical + `TextRange` (nested `{ line, character }` start/end positions) instead of a + flat `{ Start, End }` integer index pair. The flat shape was a + code-generation drift from the schema (`ChangesetOperationTarget.range` is a + `TextRange`) and could not represent a real source range; the .NET wire form + now matches the other language clients. +- `ActionEnvelope.Origin` is omitted from the wire when absent + (server-originated) instead of being serialized as `"origin": null`, matching + the `ActionOrigin | undefined` schema (`undefined` ⇒ omit). +- Client teardown could deadlock when a keep-alive ping failure triggered + shutdown from within the keep-alive loop itself (the loop awaited its own + task). Teardown now skips that self-await. +- A fragmented WebSocket text message whose frames exactly filled the 64 KiB + receive buffer dropped a frame; the receive loop now grows the buffer after + copying the previous frame rather than before. + +## [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..90b71e45 --- /dev/null +++ b/clients/dotnet/Directory.Build.props @@ -0,0 +1,99 @@ + + + + + + 12 + enable + + true + disable + true + + $(NoWarn);CS1591 + false + + + true + latest + Recommended + true + + + true + true + true + + + + + + + + + + + + + + + + + + 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 + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)VERSION').Trim()) + 0.1.0 + + + diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md new file mode 100644 index 00000000..485d8401 --- /dev/null +++ b/clients/dotnet/README.md @@ -0,0 +1,138 @@ +# 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; + +// The client takes ownership of the transport and disposes it on shutdown, +// so dispose the client (not the transport). +var transport = await WebSocketTransport.ConnectAsync(new Uri("ws://localhost:5172")); +await using 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.ReadAllAsync()) +{ + 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. + +## Dependency injection + +Register the services with `AddAgentHostProtocol` (in the +`Microsoft.Extensions.DependencyInjection` namespace): + +```csharp +services.AddAgentHostProtocol(cfg => cfg.DefaultRequestTimeout = TimeSpan.FromSeconds(10)); +``` + +That registers `IAhpSerializer`, `IClientIdStore`, `MultiHostClient`, and an +`IAhpClientFactory` as singletons. Because a client needs a live transport, +resolve the factory and call `ConnectAsync(transport)`: + +```csharp +var factory = provider.GetRequiredService(); +await using var client = await factory.ConnectAsync(transport); +``` + +The `MultiHostClient` singleton is disposed by the container on shutdown. The +`configureClient` options apply to the factory path; `MultiHostClient` hosts are +configured per host via `HostConfig.ClientConfig`. + +## Observability + +The client emits OpenTelemetry-native traces and metrics via `System.Diagnostics` +(no `ILogger` dependency) under the source/meter name `AhpTelemetry.Name` +(`"Microsoft.AgentHostProtocol"`): + +```csharp +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource(AhpTelemetry.Name)) + .WithMetrics(m => m.AddMeter(AhpTelemetry.Name)); +``` + +Spans cover requests (`ahp.request {method}`); metrics include +`ahp.client.request.duration`, `ahp.client.messages.{sent,received}`, +`ahp.client.requests.in_flight`, `ahp.client.subscriptions.active`, +`ahp.client.reconnects`, `ahp.client.events.dropped`, and +`ahp.client.frames.malformed`. All are near-zero-cost when no listener is +attached. See [`TELEMETRY.md`](TELEMETRY.md) for the full contract — the span +plus every metric name, unit, and attribute. + +## 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/TELEMETRY.md b/clients/dotnet/TELEMETRY.md new file mode 100644 index 00000000..ad392ec2 --- /dev/null +++ b/clients/dotnet/TELEMETRY.md @@ -0,0 +1,57 @@ +# Telemetry — AgentHostProtocol .NET client + +The client is instrumented natively with `System.Diagnostics` — an +`ActivitySource` (traces) and a `Meter` (metrics) — so an OpenTelemetry pipeline +lights up without the library taking any telemetry-SDK dependency or forcing a +logging framework on you. Both live in `System.Diagnostics.DiagnosticSource`, +which is in the shared framework on .NET 8+, so there is ~zero added deployed +dependency, and the instrumentation is ~zero-cost when nothing is listening +(`StartActivity()` returns `null`; span tags are built only when a listener is +attached, gated by `ActivitySource.HasListeners()`). + +## Enabling it + +The instrumentation-scope name for both the source and the meter is +`Microsoft.AgentHostProtocol` (`AhpTelemetry.Name`). Register them with your +OpenTelemetry provider: + +```csharp +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource("Microsoft.AgentHostProtocol")) + .WithMetrics(m => m.AddMeter("Microsoft.AgentHostProtocol")); +``` + +The client emits only counts, durations, and outcomes — **never message content** +— so there is no sensitive-data switch to consider. + +## Traces + +One span per JSON-RPC request: + +| Span name | Kind | Attributes | +|---|---|---| +| `ahp.request {method}` | `Client` | `rpc.system=jsonrpc`, `rpc.method`, `ahp.outcome` (`ok` \| `error` \| `cancelled` \| `timeout`); status `Ok`/`Error` | + +The name follows the OpenTelemetry `{operation} {target}` shape (e.g. +`ahp.request initialize`), and the `rpc.*` attributes follow the OTel RPC +semantic conventions. + +## Metrics + +| Metric name | Instrument | Unit | Attributes | +|---|---|---|---| +| `ahp.client.messages.sent` | Counter | `{message}` | `ahp.message.kind` (`request` \| `notification`) | +| `ahp.client.messages.received` | Counter | `{message}` | — | +| `ahp.client.request.duration` | Histogram | `ms` | `rpc.method`, `ahp.outcome` | +| `ahp.client.requests.in_flight` | UpDownCounter | `{request}` | — | +| `ahp.client.subscriptions.active` | UpDownCounter | `{subscription}` | — | +| `ahp.client.reconnects` | Counter | `{reconnect}` | `ahp.outcome` | +| `ahp.client.events.dropped` | Counter | `{event}` | `ahp.stream` (`subscription` \| `event` \| `state` \| `host-*`) | +| `ahp.client.frames.malformed` | Counter | `{frame}` | — | + +Metric names are lowercase-dotted per OpenTelemetry convention (the C# instrument +fields are PascalCase). A consumer's own `ILogger` written inside one of the +client's spans auto-correlates to that trace, so no logs are originated here. 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/codegen/telemetry/registry.test.ts b/clients/dotnet/codegen/telemetry/registry.test.ts new file mode 100644 index 00000000..872c1541 --- /dev/null +++ b/clients/dotnet/codegen/telemetry/registry.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for the telemetry registry — guards the invariants the per-language + * generators rely on: + * + * - `TELEMETRY_SOURCE` is non-empty. + * - Every span / metric / attribute NAME is lowercase-dotted (these become + * OTel instrument / attribute-key names, which OTel constrains to dotted + * lowercase). + * - Every enumerated attribute VALUE is a lowercase token, hyphens allowed — + * attribute values are NOT OTel instrument names (cf. OTel attribute values + * such as `http.request.method=GET`), so the multi-host `host-*` stream + * values are legitimately hyphenated. + * - Span / metric / attribute names are globally unique (no collisions). + * - `TELEMETRY_METRIC_UNITS` has a non-empty unit for every metric. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + TELEMETRY_SOURCE, + TELEMETRY_METRIC_UNITS, + TelemetrySpan, + TelemetryMetric, + TelemetryAttribute, + TelemetryRpcSystem, + TelemetryOutcome, + TelemetryMessageKind, + TelemetryStream, +} from './registry.js'; + +/** + * NAME shape — OTel instrument / attribute-key names are lowercase-dotted + * (no hyphens). Spans, metrics, and attribute KEYS must match this. + */ +const DOTTED_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/; + +/** + * VALUE shape — enumerated attribute VALUES are free-form lowercase tokens. + * Hyphens are permitted here (and only here): an attribute value is not an + * OTel instrument name, so `host-event` is a legitimate value even though it + * could never be a metric/span name. Still constrained to a tight charset so + * a typo like an uppercase letter or whitespace is caught. + */ +const VALUE_RE = /^[a-z][a-z0-9_]*(-[a-z0-9_]+)*$/; + +test('TELEMETRY_SOURCE is non-empty', () => { + assert.ok(TELEMETRY_SOURCE.length > 0); +}); + +test('every span / metric / attribute NAME is lowercase-dotted', () => { + for (const value of Object.values(TelemetrySpan)) { + assert.match(value, DOTTED_RE, `span not dotted: ${value}`); + } + for (const value of Object.values(TelemetryMetric)) { + assert.match(value, DOTTED_RE, `metric not dotted: ${value}`); + } + for (const value of Object.values(TelemetryAttribute)) { + assert.match(value, DOTTED_RE, `attribute not dotted: ${value}`); + } +}); + +test('every enumerated attribute VALUE is a lowercase token (hyphens allowed)', () => { + const valueEnums = [ + TelemetryRpcSystem, + TelemetryOutcome, + TelemetryMessageKind, + TelemetryStream, + ]; + for (const valueEnum of valueEnums) { + for (const value of Object.values(valueEnum)) { + assert.match(value, VALUE_RE, `attribute value not a lowercase token: ${value}`); + } + } +}); + +test('span / metric / attribute names do not collide', () => { + const names = [ + ...Object.values(TelemetrySpan), + ...Object.values(TelemetryMetric), + ...Object.values(TelemetryAttribute), + ]; + assert.equal(new Set(names).size, names.length, 'duplicate telemetry name'); +}); + +test('enumerated values within a group do not collide', () => { + for (const valueEnum of [ + TelemetryRpcSystem, + TelemetryOutcome, + TelemetryMessageKind, + TelemetryStream, + ]) { + const values = Object.values(valueEnum); + assert.equal(new Set(values).size, values.length, 'duplicate telemetry value'); + } +}); + +test('every metric has a non-empty unit', () => { + for (const metric of Object.values(TelemetryMetric)) { + const unit = TELEMETRY_METRIC_UNITS[metric]; + assert.ok(unit !== undefined && unit.length > 0, `missing unit for metric: ${metric}`); + } +}); diff --git a/clients/dotnet/codegen/telemetry/registry.ts b/clients/dotnet/codegen/telemetry/registry.ts new file mode 100644 index 00000000..75a6c871 --- /dev/null +++ b/clients/dotnet/codegen/telemetry/registry.ts @@ -0,0 +1,141 @@ +/** + * Telemetry Registry — the single source of truth for the self-instrumentation + * span / metric / attribute names THIS .NET client emits about its own + * operation, so the names live in one place and the generated + * `AhpTelemetryNames` holder cannot drift from them. + * + * This registry is .NET-client-private (it lives under the client tree, not in + * `types/`): AHP has not specced a cross-client self-instrumentation names + * contract, and a maintainer declined to take one for now (issue #239, kept on + * the backlog). The shape deliberately mirrors the protocol enums (e.g. + * `ChangesetOperationStatus`): each name is a **string enum member**, so its + * value and its `/** ... *\/` description live together and the generator + * extracts the description the same way it does for every other enum + * (`enumDecl.getMembers()` + `member.getJsDocs()`). If AHP ever specs a shared + * cross-client contract, this file moves back to `types/telemetry/registry.ts` + * and the other-language generators wire to it; only the NAMES would be + * shared, the instrumentation LOGIC staying hand-written per language. + * + * This is client SELF-instrumentation, distinct from the protocol's + * "OpenTelemetry over AHP" channel (server -> client OTLP delivery). + * + * @module telemetry/registry + */ + +/** Instrumentation-scope name used for every AHP self-instrumentation span and metric. */ +export const TELEMETRY_SOURCE = 'Microsoft.AgentHostProtocol'; + +/** Span names. One span per JSON-RPC request, named `${request} {method}`. */ +export enum TelemetrySpan { + /** Span covering a single JSON-RPC request, from send until it settles. */ + Request = 'ahp.request', +} + +/** + * Metric instrument names (lowercase-dotted per OTel convention). Units are + * carried separately in {@link TELEMETRY_METRIC_UNITS}. + */ +export enum TelemetryMetric { + /** Messages sent to the host, tagged by ahp.message.kind (request|notification). */ + MessagesSent = 'ahp.client.messages.sent', + /** Messages received from the host. */ + MessagesReceived = 'ahp.client.messages.received', + /** Round-trip duration of a JSON-RPC request, tagged by rpc.method and ahp.outcome (ok|error|cancelled|timeout). */ + RequestDuration = 'ahp.client.request.duration', + /** Requests awaiting a response. */ + RequestsInFlight = 'ahp.client.requests.in_flight', + /** Subscriptions registered with the client (decremented on unsubscribe or shutdown). */ + SubscriptionsActive = 'ahp.client.subscriptions.active', + /** Reconnect operations, tagged by outcome. */ + Reconnects = 'ahp.client.reconnects', + /** Buffered events evicted under back-pressure (drop-oldest), tagged by stream. */ + EventsDropped = 'ahp.client.events.dropped', + /** Inbound frames that failed to decode and were skipped (protocol resync is the host’s responsibility). */ + FramesMalformed = 'ahp.client.frames.malformed', +} + +/** OTel unit annotation for each metric. Trivial metadata, keyed by metric (no doc needed). */ +export const TELEMETRY_METRIC_UNITS: Record = { + [TelemetryMetric.MessagesSent]: '{message}', + [TelemetryMetric.MessagesReceived]: '{message}', + [TelemetryMetric.RequestDuration]: 'ms', + [TelemetryMetric.RequestsInFlight]: '{request}', + [TelemetryMetric.SubscriptionsActive]: '{subscription}', + [TelemetryMetric.Reconnects]: '{reconnect}', + [TelemetryMetric.EventsDropped]: '{event}', + [TelemetryMetric.FramesMalformed]: '{frame}', +}; + +/** Attribute (tag) keys. `rpc.*` follow the OTel RPC semantic conventions. */ +export enum TelemetryAttribute { + /** RPC system identifier (OTel rpc.system); always "jsonrpc" for AHP. */ + RpcSystem = 'rpc.system', + /** JSON-RPC method name the span/metric is scoped to (OTel rpc.method). */ + RpcMethod = 'rpc.method', + /** Client-assigned JSON-RPC request id. */ + RequestId = 'ahp.request.id', + /** Terminal outcome of a request or reconnect (ok|error|cancelled|timeout). */ + Outcome = 'ahp.outcome', + /** Whether a sent message was a request or a notification. */ + MessageKind = 'ahp.message.kind', + /** Which event stream a dropped or observed event belongs to. */ + Stream = 'ahp.stream', +} + +/** `rpc.system` values. */ +export enum TelemetryRpcSystem { + /** JSON-RPC — the only RPC system AHP uses. */ + Jsonrpc = 'jsonrpc', +} + +/** + * `ahp.outcome` values. NOTE: this is the SINGLE outcome vocabulary — requests + * AND reconnects both use it. The protocol models a reconnect as + * success-or-rejected (a `ReconnectResult` or a rejected JSON-RPC request), the + * same ok/error dichotomy as a request, so a reconnect tags `ok`/`error`, not a + * separate `success`/`failure` set. + */ +export enum TelemetryOutcome { + /** The request or reconnect completed successfully. */ + Ok = 'ok', + /** The request or reconnect failed with an error response. */ + Error = 'error', + /** The request was cancelled before it settled. */ + Cancelled = 'cancelled', + /** The request exceeded its configured timeout. */ + Timeout = 'timeout', +} + +/** `ahp.message.kind` values. */ +export enum TelemetryMessageKind { + /** A JSON-RPC request (expects a response). */ + Request = 'request', + /** A JSON-RPC notification (fire-and-forget). */ + Notification = 'notification', +} + +/** + * `ahp.stream` values. The `host-*` members identify the per-stream + * dropped-event channels a multi-host client (e.g. the .NET client) fans the + * host's own notifications across; they are enumerated attribute VALUES, not + * OTel instrument names, so the hyphenated spelling is intentional and + * idiomatic (cf. OTel attribute values like `http.request.method=GET`). + */ +export enum TelemetryStream { + /** A per-resource subscription stream. */ + Subscription = 'subscription', + /** The client-wide event stream. */ + Event = 'event', + /** A state-snapshot stream. */ + State = 'state', + /** A multi-host client's host-event delivery stream. */ + HostEvent = 'host-event', + /** A multi-host client's host-subscription delivery stream. */ + HostSubscription = 'host-subscription', + /** A multi-host client's host-resource delivery stream. */ + HostResource = 'host-resource', + /** A multi-host client's host-snapshot delivery stream. */ + HostSnapshot = 'host-snapshot', + /** A multi-host client's host-summaries delivery stream. */ + HostSummaries = 'host-summaries', +} 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..8baa7414 --- /dev/null +++ b/clients/dotnet/docs/decisions/serialization.md @@ -0,0 +1,145 @@ +# 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) | Source-gen capable | Strict by default; Microsoft's greenfield recommendation. | +| System.Text.Json + source generation | Highest (+startup, +AOT) | Lowest | Eager | In-box | **Best** | An AOT/trimming enhancement for later — but **not** a drop-in `[JsonSerializable]` context: see "Deferred, on purpose" — the runtime-`Type`-keyed union converters do not compose with the source generator, so it requires reshaping the generated unions, not just adding a context. | +| 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 — the + packages stay at **zero NuGet dependencies**, which is a hard goal for this + library. +- **AOT / trimming:** STJ supports source generation as a path to + Native-AOT/trimming friendliness later. Note this is **not** a free, + drop-in step for this client: the discriminated unions dispatch on a + runtime `Type`, which the source generator does not support, so the + migration is a typed-variant rewrite of the generated unions (see + "Deferred, on purpose"). Until then the shipping libraries declare the + reflection unsafety via `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` + on the serializer seam so trimmed/AOT consumers are warned at build time. +- **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 source-gen for AOT/trimming and a further perf + bump when there is a concrete AOT consumer. This is **not** merely "add a + `[JsonSerializable]` `JsonSerializerContext`." The union machinery is + fundamentally reflection-polymorphic: `UnionConverter.Read` resolves the + payload type at runtime from a `Dictionary` and calls + `root.Deserialize(variantType, options)`, and `Write` serializes via + `inner.GetType()`. The STJ source generator only emits metadata for + compile-time-known closed types and does **not** support custom converters + that dispatch on a runtime `Type`. A real source-gen migration therefore + requires reshaping every discriminated union away from the `object?`-valued + `AhpUnion` + runtime-`Type` dispatch toward a closed, statically-known variant + representation (e.g. STJ's `[JsonPolymorphic]`/`[JsonDerivedType]`, or + per-variant typed properties) — a redesign of the generated wire types plus + the codegen, not a drop-in. In the meantime the libraries opt into the + trim/AOT analyzers and annotate the reflection entry points with + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` so the limitation is + declared at build time rather than discovered at runtime. + +## 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..8617d2f4 --- /dev/null +++ b/clients/dotnet/docs/decisions/sync.md @@ -0,0 +1,114 @@ +# 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** — every exclusive critical section (`HostEntry` field-bundle, channel-list append, subscription registry, timestamp stamp). Cannot be held across `await`. | +| `System.Threading.Lock` (.NET 9 / C# 13) | exclusive lock | No — ~25% faster than `Monitor` *under contention*, but ours are near-zero-contention (see below), and it would require multi-targeting net9, now out of support. | +| `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`**. | + +### Target framework: net8.0 only + +The packages target `net8.0` (the current LTS) and run on any .NET 8+ runtime +(net9, net10, …) via forward compatibility. We considered multi-targeting net9 +so the `lock` fields could alias to `System.Threading.Lock` (~25% faster than +`Monitor` under contention), but rejected it: the lock sites here are +near-zero-contention small in-memory sections, so that optimization buys nothing +in practice, and net9 is now out of support. `Monitor` (`lock (object)`) +everywhere is simpler and identical for this workload. + +## 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). +- The single `net8.0` build runs on any .NET 8+ runtime; the `lock` fields use + the standard `Monitor`. + +## 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..95c8771d --- /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 = AhpClient.Connect(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/OtelExport/OtelExport.csproj b/clients/dotnet/examples/OtelExport/OtelExport.csproj new file mode 100644 index 00000000..391c1bf1 --- /dev/null +++ b/clients/dotnet/examples/OtelExport/OtelExport.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + Exe + false + false + + Major + + + + + + + + + + + + + diff --git a/clients/dotnet/examples/OtelExport/Program.cs b/clients/dotnet/examples/OtelExport/Program.cs new file mode 100644 index 00000000..e402335e --- /dev/null +++ b/clients/dotnet/examples/OtelExport/Program.cs @@ -0,0 +1,167 @@ +// Shape C — consumer self-instrumentation. Wires the AHP client's +// OpenTelemetry-native instrumentation into a real OpenTelemetry pipeline with a +// console exporter, then drives ONE client operation so the resulting spans + +// metrics print to stdout. +// +// The AHP library takes NO OpenTelemetry dependency — it originates only BCL +// System.Diagnostics ActivitySource + Meter instruments, near-zero-cost when no +// listener is attached. A consumer "lights them up" exactly as shown below: +// +// .AddSource(AhpTelemetry.Name) // traces +// .AddMeter(AhpTelemetry.Name) // metrics +// +// (AhpTelemetry.Name == AhpServiceCollectionExtensions.TelemetrySourceName == +// AhpTelemetryNames.Source == "Microsoft.AgentHostProtocol"). +// +// To keep the example self-contained (no external AHP server required), it talks +// to a tiny in-process loopback ITransport that answers the `initialize` +// handshake. The instrumentation that fires is identical to a real connection's. +// +// Usage: dotnet run --project examples/OtelExport +#nullable enable + +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +// ── 1. Build the OpenTelemetry pipelines, wiring in the AHP instrumentation. ── +// AddSource/AddMeter take the AHP instrumentation-scope name; the console +// exporter prints every captured span + metric. A real app would swap the +// console exporter for OTLP/Jaeger/Prometheus — the AddSource/AddMeter lines are +// the only AHP-specific wiring. +var resource = ResourceBuilder.CreateDefault().AddService("ahp-otel-example"); + +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(resource) + .AddSource(AhpTelemetry.Name) + .AddConsoleExporter() + .Build(); + +using var meterProvider = Sdk.CreateMeterProviderBuilder() + .SetResourceBuilder(resource) + .AddMeter(AhpTelemetry.Name) + .AddConsoleExporter() + .Build(); + +// ── 2. Drive one real client operation so the instrumentation fires. ───────── +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + +var (clientTransport, serverTransport) = LoopbackTransport.CreatePair(); +var serverTask = Task.Run(() => RunInitializeResponderAsync(serverTransport, cts.Token), cts.Token); + +await using (var client = AhpClient.Connect(clientTransport)) +{ + Console.WriteLine("→ running initialize handshake (instrumented)…"); + var init = await client.InitializeAsync("ahp-otel-example", cancellationToken: cts.Token); + Console.WriteLine($"← negotiated protocol version: {init.ProtocolVersion}"); +} + +await serverTask; + +// ── 3. Flush the exporters so the spans + metrics print before the process ─── +// exits. Disposing the providers (the `using` above) also flushes, but an +// explicit ForceFlush makes the console output deterministic for the demo. +tracerProvider.ForceFlush(); +meterProvider.ForceFlush(); + +Console.WriteLine(); +Console.WriteLine("The 'Activity.TraceId'/'ahp.request initialize' span above is the AHP request span;"); +Console.WriteLine("the 'ahp.client.*' instruments are the AHP metrics — both via .AddSource/.AddMeter(AhpTelemetry.Name)."); +return 0; + +// ── In-process responder ────────────────────────────────────────────────── +// Answers the single `initialize` request with a stub InitializeResult, so the +// client's request span settles Ok and the request.duration / messages.* / +// requests.in_flight metrics all record. Mirrors the test fake-server shape but +// uses only the PUBLIC serializer + transport surface. +static async Task RunInitializeResponderAsync(LoopbackTransport serverSide, CancellationToken ct) +{ + var serializer = SystemTextJsonAhpSerializer.Default; + try + { + var frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); + var msg = serializer.DecodeMessage(frame); + if (msg.Request is { Method: "initialize" } request) + { + var result = new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, + Snapshots = new(), + }; + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = request.Id, + Result = serializer.SerializeToElement(result), + }, + }; + await serverSide.SendAsync(serializer.EncodeMessage(response), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Demo finished; the responder's wait was cancelled — expected. + } +} + +// ── Minimal in-memory ITransport pair ────────────────────────────────────── +// A self-contained loopback so the example needs no external AHP server. Frames +// written to one side appear on the other. Closing either side closes both. +// Demonstrates that ITransport is a small, public, pluggable seam. +internal sealed class LoopbackTransport : ITransport +{ + private readonly ChannelReader _inbox; + private readonly ChannelWriter _outbox; + private readonly CancellationTokenSource _closeCts; + + private LoopbackTransport( + ChannelReader inbox, + ChannelWriter outbox, + CancellationTokenSource closeCts) + { + _inbox = inbox; + _outbox = outbox; + _closeCts = closeCts; + } + + public static (LoopbackTransport Client, LoopbackTransport Server) CreatePair() + { + var c2s = Channel.CreateUnbounded(); + var s2c = Channel.CreateUnbounded(); + var cts = new CancellationTokenSource(); // closing either side closes both + var client = new LoopbackTransport(s2c.Reader, c2s.Writer, cts); + var server = new LoopbackTransport(c2s.Reader, s2c.Writer, cts); + return (client, server); + } + + public async ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closeCts.Token); + try { await _outbox.WriteAsync(message, linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (_closeCts.IsCancellationRequested) + { throw new TransportClosedException("loopback closed"); } + } + + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closeCts.Token); + try { return await _inbox.ReadAsync(linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (_closeCts.IsCancellationRequested) + { throw new TransportClosedException("loopback closed"); } + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _closeCts.Cancel(); + _outbox.TryComplete(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => CloseAsync(); +} diff --git a/clients/dotnet/examples/ReducersDemo/Program.cs b/clients/dotnet/examples/ReducersDemo/Program.cs new file mode 100644 index 00000000..28d2e08a --- /dev/null +++ b/clients/dotnet/examples/ReducersDemo/Program.cs @@ -0,0 +1,75 @@ +// Applies a handful of chat actions to an empty ChatState to illustrate +// the public reducer API. Post-#213 multi-chat sessions: chat-channel actions +// now target ChatState, not SessionState directly. +// 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 chatState = new ChatState +{ + Resource = "ahp-session:/demo/chat/main", + Title = "Demo Chat", + Status = SessionStatus.Idle, + ModifiedAt = "1970-01-01T00:00:00.000Z", + Turns = new List(), +}; + +var actions = new List +{ + new StateAction(new ChatTurnStartedAction + { + Type = ActionType.ChatTurnStarted, + TurnId = "t1", + Message = new Message + { + Text = "Hello!", + Origin = new MessageOrigin { Kind = MessageKind.User }, + }, + }), + new StateAction(new ChatResponsePartAction + { + Type = ActionType.ChatResponsePart, + TurnId = "t1", + Part = new ResponsePart(new MarkdownResponsePart + { + Kind = ResponsePartKind.Markdown, + Id = "p1", + Content = "Hi ", + }), + }), + new StateAction(new ChatDeltaAction + { + Type = ActionType.ChatDelta, + TurnId = "t1", + PartId = "p1", + Content = "there!", + }), + new StateAction(new ChatTurnCompleteAction + { + Type = ActionType.ChatTurnComplete, + TurnId = "t1", + }), +}; + +foreach (var action in actions) +{ + var outcome = Reducers.ApplyToChat(chatState, action); + Console.WriteLine($"applied {action.Value?.GetType().Name} → {OutcomeName(outcome)}"); +} + +var options = new JsonSerializerOptions { WriteIndented = true }; +var pretty = JsonSerializer.Serialize(chatState, 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/global.json b/clients/dotnet/global.json new file mode 100644 index 00000000..d07970ac --- /dev/null +++ b/clients/dotnet/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "latestMajor" + } +} diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json new file mode 100644 index 00000000..fb741043 --- /dev/null +++ b/clients/dotnet/release-metadata.json @@ -0,0 +1,9 @@ +{ + "client": "dotnet", + "packageVersion": "0.3.0", + "supportedProtocolVersions": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] +} 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..d96e8ec1 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj @@ -0,0 +1,39 @@ + + + + Microsoft.AgentHostProtocol + net8.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. + + + true + true + + + + + + + + + + + + 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..609e1f7e --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -0,0 +1,2147 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +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/chatAdded")] + SessionChatAdded, + [WireValue("session/chatRemoved")] + SessionChatRemoved, + [WireValue("session/chatUpdated")] + SessionChatUpdated, + [WireValue("session/defaultChatChanged")] + SessionDefaultChatChanged, + [WireValue("chat/turnStarted")] + ChatTurnStarted, + [WireValue("chat/delta")] + ChatDelta, + [WireValue("chat/responsePart")] + ChatResponsePart, + [WireValue("chat/toolCallStart")] + ChatToolCallStart, + [WireValue("chat/toolCallDelta")] + ChatToolCallDelta, + [WireValue("chat/toolCallReady")] + ChatToolCallReady, + [WireValue("chat/toolCallConfirmed")] + ChatToolCallConfirmed, + [WireValue("chat/toolCallComplete")] + ChatToolCallComplete, + [WireValue("chat/toolCallResultConfirmed")] + ChatToolCallResultConfirmed, + [WireValue("chat/toolCallContentChanged")] + ChatToolCallContentChanged, + [WireValue("chat/turnComplete")] + ChatTurnComplete, + [WireValue("chat/turnCancelled")] + ChatTurnCancelled, + [WireValue("chat/error")] + ChatError, + [WireValue("session/titleChanged")] + SessionTitleChanged, + [WireValue("chat/usage")] + ChatUsage, + [WireValue("chat/reasoning")] + ChatReasoning, + [WireValue("session/modelChanged")] + SessionModelChanged, + [WireValue("session/agentChanged")] + SessionAgentChanged, + [WireValue("session/serverToolsChanged")] + SessionServerToolsChanged, + [WireValue("session/activeClientSet")] + SessionActiveClientSet, + [WireValue("session/activeClientRemoved")] + SessionActiveClientRemoved, + [WireValue("chat/pendingMessageSet")] + ChatPendingMessageSet, + [WireValue("chat/pendingMessageRemoved")] + ChatPendingMessageRemoved, + [WireValue("chat/queuedMessagesReordered")] + ChatQueuedMessagesReordered, + [WireValue("chat/inputRequested")] + ChatInputRequested, + [WireValue("chat/inputAnswerChanged")] + ChatInputAnswerChanged, + [WireValue("chat/inputCompleted")] + ChatInputCompleted, + [WireValue("session/customizationsChanged")] + SessionCustomizationsChanged, + [WireValue("session/customizationToggled")] + SessionCustomizationToggled, + [WireValue("session/customizationUpdated")] + SessionCustomizationUpdated, + [WireValue("session/customizationRemoved")] + SessionCustomizationRemoved, + [WireValue("session/mcpServerStateChanged")] + SessionMcpServerStateChanged, + [WireValue("chat/truncated")] + ChatTruncated, + [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/contentChanged")] + ChangesetContentChanged, + [WireValue("changeset/operationsChanged")] + ChangesetOperationsChanged, + [WireValue("changeset/operationStatusChanged")] + ChangesetOperationStatusChanged, + [WireValue("changeset/cleared")] + ChangesetCleared, + [WireValue("annotations/set")] + AnnotationsSet, + [WireValue("annotations/updated")] + AnnotationsUpdated, + [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 record ActionOrigin +{ + public required string ClientId { get; init; } + + public long ClientSeq { get; init; } +} + +/// +/// ActionEnvelope wraps every action with the channel URI it belongs to, +/// the server-assigned monotonic sequence number, and an optional origin. +/// +public sealed record ActionEnvelope +{ + public required string Channel { get; init; } + + public required StateAction Action { get; init; } + + public long ServerSeq { get; init; } + + /// The origin of the action. Required-nullable in the wire schema + /// (ActionOrigin | undefined): omitted when absent (server-originated), + /// never serialized as null. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ActionOrigin? Origin { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RejectionReason { get; init; } +} + +// ─── Action Payloads ───────────────────────────────────────────────── + +/// Fired when available agent backends or their models change. +public sealed record RootAgentsChangedAction +{ + public ActionType Type { get; init; } + + /// Updated agent list + public required List Agents { get; init; } +} + +/// Fired when the number of active sessions changes. +public sealed record RootActiveSessionsChangedAction +{ + public ActionType Type { get; init; } + + /// Current count of active sessions + public long ActiveSessions { get; init; } +} + +/// 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 record RootConfigChangedAction +{ + public ActionType Type { get; init; } + + /// Updated config values + public required Dictionary Config { get; init; } + + /// When `true`, replaces all config values instead of merging + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Replace { get; init; } +} + +/// Session backend initialized successfully. +public sealed record SessionReadyAction +{ + public ActionType Type { get; init; } +} + +/// Session backend failed to initialize. +public sealed record SessionCreationFailedAction +{ + public ActionType Type { get; init; } + + /// Error details + public required ErrorInfo Error { get; init; } +} + +public sealed record SessionTurnStartedAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// The new message + public required Message Message { get; init; } + + /// If this turn was auto-started from a queued message, the ID of that message + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QueuedMessageId { get; init; } +} + +/// 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 record SessionDeltaAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Identifier of the response part to append to + public required string PartId { get; init; } + + /// Text chunk + public required string Content { get; init; } +} + +/// Structured content appended to the response. +public sealed record SessionResponsePartAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Response part (markdown or content ref) + public required ResponsePart Part { get; init; } +} + +/// A tool call begins — parameters are streaming from the LM. +public sealed record SessionToolCallStartAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } +} + +/// Streaming partial parameters for a tool call. +public sealed record SessionToolCallDeltaAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Partial parameter content to append + public required string Content { get; init; } + + /// Updated progress message + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? InvocationMessage { get; init; } +} + +/// Tool call parameters are complete, or a running tool requires re-confirmation. +public sealed record SessionToolCallReadyAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Message describing what the tool will do or what confirmation is needed + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + /// Short title for the confirmation prompt + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ConfirmationTitle { get; init; } + + /// File edits that this tool call will perform, for preview before confirmation + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Edits { get; init; } + + /// Whether the agent host allows the client to edit the tool's input parameters before confirming + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Editable { get; init; } + + /// If set, the tool was auto-confirmed and transitions directly to `running` + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + /// Options the server offers for this confirmation. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; init; } +} + +/// Tool execution finished. +public sealed record SessionToolCallCompleteAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Execution result + public required ToolCallResult Result { get; init; } + + /// If true, the result requires client approval before finalizing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RequiresResultConfirmation { get; init; } +} + +/// Client approves or denies a tool's result. +public sealed record SessionToolCallResultConfirmedAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Whether the result was approved + public bool Approved { get; init; } +} + +/// Turn finished — the assistant is idle. +public sealed record SessionTurnCompleteAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } +} + +/// Turn was aborted; server stops processing. +public sealed record SessionTurnCancelledAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } +} + +/// Error during turn processing. +public sealed record SessionErrorAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Error details + public required ErrorInfo Error { get; init; } +} + +/// Token usage report for a turn. +public sealed record SessionUsageAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Token usage data + public required UsageInfo Usage { get; init; } +} + +/// Reasoning/thinking text from the model, appended to a specific reasoning response part. +public sealed record SessionReasoningAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Identifier of the reasoning response part to append to + public required string PartId { get; init; } + + /// Reasoning text chunk + public required string Content { get; init; } +} + +/// A pending message was set (upsert semantics: creates or replaces). +public sealed record SessionPendingMessageSetAction +{ + public ActionType Type { get; init; } + + /// Whether this is a steering or queued message + public PendingMessageKind Kind { get; init; } + + /// Unique identifier for this pending message + public required string Id { get; init; } + + /// The message content + public required Message Message { get; init; } +} + +/// A pending message was removed (steering or queued). +public sealed record SessionPendingMessageRemovedAction +{ + public ActionType Type { get; init; } + + /// Whether this is a steering or queued message + public PendingMessageKind Kind { get; init; } + + /// Identifier of the pending message to remove + public required string Id { get; init; } +} + +/// Reorder the queued messages. +public sealed record SessionQueuedMessagesReorderedAction +{ + public ActionType Type { get; init; } + + /// Queued message IDs in the desired order + public required List Order { get; init; } +} + +/// A session requested input from the user. +public sealed record SessionInputRequestedAction +{ + public ActionType Type { get; init; } + + /// Input request to create or replace + public required SessionInputRequest Request { get; init; } +} + +/// A client updated, submitted, skipped, or removed a single in-progress answer. +public sealed record SessionInputAnswerChangedAction +{ + public ActionType Type { get; init; } + + /// Input request identifier + public required string RequestId { get; init; } + + /// Question identifier within the input request + public required string QuestionId { get; init; } + + /// Updated answer, or undefined to clear an answer draft + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionInputAnswer? Answer { get; init; } +} + +/// A client accepted, declined, or cancelled a session input request. +public sealed record SessionInputCompletedAction +{ + public ActionType Type { get; init; } + + /// Input request identifier + public required string RequestId { get; init; } + + /// Completion outcome + public SessionInputResponseKind Response { get; init; } + + /// Optional final answer replacement, keyed by question ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; init; } +} + +/// +/// SessionToolCallConfirmedAction — the client approves or denies a +/// pending tool call (merged approved + denied variants on the wire). +/// +public sealed record SessionToolCallConfirmedAction +{ + public ActionType Type { get; init; } + + public required string TurnId { get; init; } + + public required string ToolCallId { get; init; } + + // _meta is snake_case; camelCase(Meta) would be "meta". + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public bool Approved { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallCancellationReason? Reason { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditedToolInput { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SelectedOptionId { get; init; } +} + +/// 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 record SessionTitleChangedAction +{ + public ActionType Type { get; init; } + + /// New title + public required string Title { get; init; } +} + +/// Model changed for this session. +public sealed record SessionModelChangedAction +{ + public ActionType Type { get; init; } + + /// New model selection + public required ModelSelection Model { get; init; } +} + +/// 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 record SessionAgentChangedAction +{ + public ActionType Type { get; init; } + + /// New agent selection, or `undefined` to clear the selection and reset the + /// session to no selected custom agent. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; init; } +} + +/// 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 record SessionIsReadChangedAction +{ + public ActionType Type { get; init; } + + /// Whether the session has been read + public bool IsRead { get; init; } +} + +/// 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 record SessionIsArchivedChangedAction +{ + public ActionType Type { get; init; } + + /// Whether the session is archived + public bool IsArchived { get; init; } +} + +/// 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 record SessionActivityChangedAction +{ + public ActionType Type { get; init; } + + /// Human-readable description of current activity, or `undefined` to clear + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; init; } +} + +/// 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 record SessionChangesetsChangedAction +{ + public ActionType Type { get; init; } + + /// New catalogue, or `undefined` to clear it + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Changesets { get; init; } +} + +/// Server tools for this session have changed. +/// +/// Full-replacement semantics: the `tools` array replaces the previous `serverTools` entirely. +public sealed record SessionServerToolsChangedAction +{ + public ActionType Type { get; init; } + + /// Updated server tools list (full replacement) + public required List Tools { get; init; } +} + +/// An active client for this session was added or updated. +/// +/// Upsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}: +/// a client dispatches this action with its own `SessionActiveClient` to join +/// the session's active clients or refresh its entry, replacing any existing +/// entry that has the same `clientId`. Multiple clients may be active at once. +/// This is also how a client updates its published tools or customizations — +/// re-dispatch with the full, updated entry. Use +/// {@link SessionActiveClientRemovedAction | `session/activeClientRemoved`} to +/// leave. The server SHOULD automatically dispatch that removal when an active +/// client disconnects. +public sealed record SessionActiveClientSetAction +{ + public ActionType Type { get; init; } + + /// The active client to add or update, matched by `clientId`. + public required SessionActiveClient ActiveClient { get; init; } +} + +/// An active client was removed from this session. +/// +/// Removes the entry for the client identified by `clientId` from +/// {@link SessionState.activeClients}; a no-op when no entry matches. +/// +/// The host SHOULD dispatch this automatically when a client stops participating +/// in the session — for example when it unsubscribes from the session channel, +/// when it disconnects and does not reconnect within a host-defined grace +/// period, or when a `reconnect` command's `subscriptions` omit a session the +/// client was still active in. When removing a client, the host SHOULD also +/// cancel that client's in-flight tool calls — those whose tool call state +/// carries a client `ToolCallContributor` with the matching `clientId` — by +/// dispatching `chat/toolCallComplete` with `result.success = false`. (There is +/// no per-tool-call server cancel; a failed completion is the cancellation +/// mechanism, and the call ends in `completed` status with a failed result.) +public sealed record SessionActiveClientRemovedAction +{ + public ActionType Type { get; init; } + + /// The `clientId` of the active client to remove. + public required string ClientId { get; init; } +} + +/// The session's customizations have changed. +/// +/// Full-replacement semantics: the `customizations` array replaces the +/// previous `customizations` entirely. +public sealed record SessionCustomizationsChangedAction +{ + public ActionType Type { get; init; } + + /// Updated customization list (full replacement). + public required List Customizations { get; init; } +} + +/// 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 record SessionCustomizationToggledAction +{ + public ActionType Type { get; init; } + + /// The id of the container to toggle. + public required string Id { get; init; } + + /// Whether to enable or disable the container. + public bool Enabled { get; init; } +} + +/// 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 record SessionCustomizationUpdatedAction +{ + public ActionType Type { get; init; } + + /// The customization to upsert (matched by `customization.id`). + public required Customization Customization { get; init; } +} + +/// 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 record SessionCustomizationRemovedAction +{ + public ActionType Type { get; init; } + + /// The id of the customization to remove. + public required string Id { get; init; } +} + +/// 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 record SessionMcpServerStateChangedAction +{ + public ActionType Type { get; init; } + + /// The id of the {@link McpServerCustomization} to update. + public required string Id { get; init; } + + /// The new lifecycle state. + public required McpServerState State { get; init; } + + /// Updated `mcp://` side-channel URI. Full-replacement: omit to clear + /// an existing channel (typical when leaving + /// {@link McpServerStatus.Ready | `Ready`}). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Channel { get; init; } +} + +/// 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 record SessionTruncatedAction +{ + public ActionType Type { get; init; } + + /// Keep turns up to and including this turn. Omit to clear all turns. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; init; } +} + +/// 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 record SessionConfigChangedAction +{ + public ActionType Type { get; init; } + + /// Updated config values + public required Dictionary Config { get; init; } + + /// When `true`, replaces all config values instead of merging + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Replace { get; init; } +} + +/// 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 record SessionMetaChangedAction +{ + public ActionType Type { get; init; } + + /// New `_meta` payload, or `undefined` to clear it + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// 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 record SessionToolCallContentChangedAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// The current partial content for the running tool call + public required List Content { get; init; } +} + +/// A chat was added to this session's catalog. Upsert semantics: if a chat +/// with the same `summary.resource` already exists, the existing entry is +/// replaced. +/// +/// Mirrors the root-channel `root/sessionAdded` notification. +public sealed record SessionChatAddedAction +{ + public ActionType Type { get; init; } + + /// The full summary of the newly added (or upserted) chat. + public required ChatSummary Summary { get; init; } +} + +/// A chat was removed from this session's catalog. No-op when no entry matches. +/// +/// Mirrors the root-channel `root/sessionRemoved` notification. +public sealed record SessionChatRemovedAction +{ + public ActionType Type { get; init; } + + /// The URI of the chat to remove. + public required string Chat { get; init; } +} + +/// One existing chat's summary fields changed. +/// +/// Partial-update semantics: only fields present in `changes` are written; +/// omitted fields are preserved. Identity fields (`resource`) MUST NOT be +/// carried in `changes`. No-op when no entry with `chat` exists — clients +/// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. +/// +/// Mirrors the root-channel `root/sessionSummaryChanged` notification. +public sealed record SessionChatUpdatedAction +{ + public ActionType Type { get; init; } + + /// The URI of the chat whose summary changed. + public required string Chat { get; init; } + + /// Mutable summary fields that changed; omitted fields are unchanged. + /// + /// Identity fields (`resource`) never change and MUST be omitted by + /// senders; receivers SHOULD ignore them if present. + public required PartialChatSummary Changes { get; init; } +} + +/// The default chat input-routing hint for this session changed. +public sealed record SessionDefaultChatChangedAction +{ + public ActionType Type { get; init; } + + /// New default chat URI, or `undefined` to clear the hint. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultChat { get; init; } +} + +/// 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 record ChatTurnStartedAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// The new message + public required Message Message { get; init; } + + /// If this turn was auto-started from a queued message, the ID of that message + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QueuedMessageId { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// Streaming text chunk from the assistant, appended to a specific response part. +/// +/// The server MUST first emit a `chat/responsePart` to create the target +/// part (markdown or reasoning), then use this action to append text to it. +public sealed record ChatDeltaAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Identifier of the response part to append to + public required string PartId { get; init; } + + /// Text chunk + public required string Content { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// Structured content appended to the response. +public sealed record ChatResponsePartAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Response part (markdown or content ref) + public required ResponsePart Part { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// 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 `chat/toolCallComplete`. For MCP-served tools, the +/// server executes the call against the named `McpServerCustomization`. +public sealed record ChatToolCallStartAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. Absent for + /// server-side tools that are not contributed by a client or MCP server. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } +} + +/// Streaming partial parameters for a tool call. +public sealed record ChatToolCallDeltaAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// Partial parameter content to append + public required string Content { get; init; } + + /// Updated progress message + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? InvocationMessage { get; init; } +} + +/// 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 `chat/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 record ChatToolCallReadyAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// Message describing what the tool will do or what confirmation is needed + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + /// Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ConfirmationTitle { get; init; } + + /// File edits that this tool call will perform, for preview before confirmation + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Edits { get; init; } + + /// Whether the agent host allows the client to edit the tool's input parameters before confirming + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Editable { get; init; } + + /// If set, the tool was auto-confirmed and transitions directly to `running` + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; init; } +} + +/// +/// ChatToolCallConfirmedAction — the client approves or denies a +/// pending chat tool call (merged approved + denied variants on the wire). +/// +public sealed record ChatToolCallConfirmedAction +{ + public ActionType Type { get; init; } + + public required string TurnId { get; init; } + + public required string ToolCallId { get; init; } + + // _meta is snake_case; camelCase(Meta) would be "meta". + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public bool Approved { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallCancellationReason? Reason { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditedToolInput { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SelectedOptionId { get; init; } +} + +/// Tool execution finished. Transitions to `completed` or `pending-result-confirmation` +/// if `requiresResultConfirmation` is `true`. +/// +/// For client-provided tools (whose tool call state carries a client +/// `ToolCallContributor` with a `clientId`), the owning client dispatches this +/// action with the execution result. The server SHOULD reject this action if the +/// dispatching client does not match the contributor's `clientId`. +/// +/// 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 record ChatToolCallCompleteAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// Execution result + public required ToolCallResult Result { get; init; } + + /// If true, the result requires client approval before finalizing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RequiresResultConfirmation { get; init; } +} + +/// Client approves or denies a tool's result. +/// +/// If `approved` is `false`, the tool transitions to `cancelled` with reason `result-denied`. +public sealed record ChatToolCallResultConfirmedAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// Whether the result was approved + public bool Approved { get; init; } +} + +/// 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 (whose tool call state carries a client +/// `ToolCallContributor` with a `clientId`), 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 the contributor's +/// `clientId`. +public sealed record ChatToolCallContentChangedAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// The current partial content for the running tool call + public required List Content { get; init; } +} + +/// Turn finished — the assistant is idle. +public sealed record ChatTurnCompleteAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// Turn was aborted; server stops processing. +public sealed record ChatTurnCancelledAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// Error during turn processing. +public sealed record ChatErrorAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Error details + public required ErrorInfo Error { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// Token usage report for a turn. +public sealed record ChatUsageAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Token usage data + public required UsageInfo Usage { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// Reasoning/thinking text from the model, appended to a specific reasoning response part. +/// +/// The server MUST first emit a `chat/responsePart` to create the target +/// reasoning part, then use this action to append text to it. +public sealed record ChatReasoningAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Identifier of the reasoning response part to append to + public required string PartId { get; init; } + + /// Reasoning text chunk + public required string Content { get; init; } + + /// Additional provider-specific metadata for this action. + /// + /// Clients MAY look for well-known keys here to provide enhanced UI, and + /// agent hosts MAY use it to carry per-event context that does not fit any + /// other field — for example, attributing the event to a specific agent + /// (such as a sub-agent acting within the turn). Mirrors the MCP `_meta` + /// convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// 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 chat status +/// returns to `idle`. +/// +/// Common use-case: truncate old data then dispatch a new +/// `chat/turnStarted` with an edited message. +public sealed record ChatTruncatedAction +{ + public ActionType Type { get; init; } + + /// Keep turns up to and including this turn. Omit to clear all turns. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; init; } +} + +/// 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 chat 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 record ChatPendingMessageSetAction +{ + public ActionType Type { get; init; } + + /// Whether this is a steering or queued message + public PendingMessageKind Kind { get; init; } + + /// Unique identifier for this pending message + public required string Id { get; init; } + + /// The message content + public required Message Message { get; init; } +} + +/// 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 record ChatPendingMessageRemovedAction +{ + public ActionType Type { get; init; } + + /// Whether this is a steering or queued message + public PendingMessageKind Kind { get; init; } + + /// Identifier of the pending message to remove + public required string Id { get; init; } +} + +/// 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 record ChatQueuedMessagesReorderedAction +{ + public ActionType Type { get; init; } + + /// Queued message IDs in the desired order + public required List Order { get; init; } +} + +/// 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 record ChatInputRequestedAction +{ + public ActionType Type { get; init; } + + /// Input request to create or replace + public required ChatInputRequest Request { get; init; } +} + +/// A client updated, submitted, skipped, or removed a single in-progress answer. +/// +/// Dispatching with `answer: undefined` removes that question's answer draft. +public sealed record ChatInputAnswerChangedAction +{ + public ActionType Type { get; init; } + + /// Input request identifier + public required string RequestId { get; init; } + + /// Question identifier within the input request + public required string QuestionId { get; init; } + + /// Updated answer, or `undefined` to clear an answer draft + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatInputAnswer? Answer { get; init; } +} + +/// 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 record ChatInputCompletedAction +{ + public ActionType Type { get; init; } + + /// Input request identifier + public required string RequestId { get; init; } + + /// Completion outcome + public ChatInputResponseKind Response { get; init; } + + /// Optional final answer replacement, keyed by question ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; init; } +} + +/// 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 record ChangesetStatusChangedAction +{ + public ActionType Type { get; init; } + + /// New computation lifecycle status. + public ChangesetStatus Status { get; init; } + + /// Cause when `status === ChangesetStatus.Error`; otherwise omitted. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; init; } +} + +/// Upsert a {@link ChangesetFile} in the changeset — adds a new entry, or +/// replaces an existing one identified by {@link ChangesetFile.id}. +public sealed record ChangesetFileSetAction +{ + public ActionType Type { get; init; } + + /// The new or replacement file entry. + public required ChangesetFile File { get; init; } +} + +/// 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 record ChangesetFileRemovedAction +{ + public ActionType Type { get; init; } + + /// The {@link ChangesetFile.id} of the file to remove. + public required string FileId { get; init; } +} + +/// The changeset's full content changed. Full replacement semantics: `files` +/// replaces the previous file list, and `operations`, when present, replaces +/// the previous operation list. +/// +/// Producers SHOULD use this action for initial snapshots and bulk refreshes; +/// use {@link ChangesetFileSetAction}, {@link ChangesetFileRemovedAction}, and +/// {@link ChangesetOperationsChangedAction} for incremental updates. +public sealed record ChangesetContentChangedAction +{ + public ActionType Type { get; init; } + + /// Full replacement file list. + public required List Files { get; init; } + + /// Full replacement operation list. Omit when operations are unchanged. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Operations { get; init; } + + /// Error information, if the changeset content change failed. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; init; } +} + +/// 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 record ChangesetOperationsChangedAction +{ + public ActionType Type { get; init; } + + /// Updated operation list. Pass `undefined` to clear all operations. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Operations { get; init; } +} + +/// 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 record ChangesetOperationStatusChangedAction +{ + public ActionType Type { get; init; } + + /// The {@link ChangesetOperation.id} whose status changed. + public required string OperationId { get; init; } + + /// New execution status. + public ChangesetOperationStatus Status { get; init; } + + /// Cause when `status === ChangesetOperationStatus.Error`; otherwise omitted. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; init; } +} + +/// 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 record ChangesetClearedAction +{ + public ActionType Type { get; init; } +} + +/// Fired when the list of known terminals changes. +/// +/// Full-replacement semantics: the `terminals` array replaces the previous +/// `terminals` entirely. +public sealed record RootTerminalsChangedAction +{ + public ActionType Type { get; init; } + + /// Updated terminal list (full replacement) + public required List Terminals { get; init; } +} + +/// 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 record TerminalDataAction +{ + public ActionType Type { get; init; } + + /// Output data (may contain ANSI escape sequences) + public required string Data { get; init; } +} + +/// 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 record TerminalInputAction +{ + public ActionType Type { get; init; } + + /// Input data to send to the pty + public required string Data { get; init; } +} + +/// Terminal dimensions changed. +/// +/// Dispatchable by clients to request a resize, or by the server to inform +/// clients of the actual terminal dimensions. +public sealed record TerminalResizedAction +{ + public ActionType Type { get; init; } + + /// Terminal width in columns + public long Cols { get; init; } + + /// Terminal height in rows + public long Rows { get; init; } +} + +/// 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 record TerminalClaimedAction +{ + public ActionType Type { get; init; } + + /// The new claim + public required TerminalClaim Claim { get; init; } +} + +/// 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 record TerminalTitleChangedAction +{ + public ActionType Type { get; init; } + + /// New terminal title + public required string Title { get; init; } +} + +/// Terminal working directory changed. +public sealed record TerminalCwdChangedAction +{ + public ActionType Type { get; init; } + + /// New working directory + public required string Cwd { get; init; } +} + +/// Terminal process exited. +public sealed record TerminalExitedAction +{ + public ActionType Type { get; init; } + + /// Process exit code. `undefined` if the process was killed without an exit code. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; init; } +} + +/// Terminal scrollback buffer cleared. +public sealed record TerminalClearedAction +{ + public ActionType Type { get; init; } +} + +/// 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 record TerminalCommandDetectionAvailableAction +{ + public ActionType Type { get; init; } +} + +/// 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 record TerminalCommandExecutedAction +{ + public ActionType Type { get; init; } + + /// Stable identifier for this command, scoped to the terminal URI. + /// Allows correlating `commandExecuted` → `commandFinished` pairs. + public required string CommandId { get; init; } + + /// The command line text that was submitted + public required string CommandLine { get; init; } + + /// Unix timestamp (ms) of when the command started executing, as measured + /// on the server. + public long Timestamp { get; init; } +} + +/// 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 record TerminalCommandFinishedAction +{ + public ActionType Type { get; init; } + + /// Matches the `commandId` from the corresponding `commandExecuted` + public required string CommandId { get; init; } + + /// Shell exit code. `undefined` if the shell did not report one. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; init; } + + /// Wall-clock duration of the command in milliseconds, as measured by the + /// shell integration script on the server side. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? DurationMs { get; init; } +} + +/// 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 record ResourceWatchChangedAction +{ + public ActionType Type { get; init; } + + /// The set of changes in this batch, wrapped for forward compatibility. + public JsonElement Changes { get; init; } +} + +/// 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, and +/// {@link AnnotationsUpdatedAction} to resolve / re-anchor an existing +/// annotation, to keep wire updates small. +public sealed record AnnotationsSetAction +{ + public ActionType Type { get; init; } + + /// The new or replacement annotation. MUST contain at least one entry. + public required Annotation Annotation { get; init; } +} + +/// 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 record AnnotationsRemovedAction +{ + public ActionType Type { get; init; } + + /// The {@link Annotation.id} of the annotation to remove. + public required string AnnotationId { get; init; } +} + +/// 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 record AnnotationsEntrySetAction +{ + public ActionType Type { get; init; } + + /// The {@link Annotation.id} the entry belongs to. + public required string AnnotationId { get; init; } + + /// The new or replacement entry. + public required AnnotationEntry Entry { get; init; } +} + +/// 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 record AnnotationsEntryRemovedAction +{ + public ActionType Type { get; init; } + + /// The {@link Annotation.id} the entry belongs to. + public required string AnnotationId { get; init; } + + /// The {@link AnnotationEntry.id} to remove. + public required string EntryId { get; init; } +} + +/// Partially update an existing {@link Annotation}'s own properties — a narrow +/// alternative to {@link AnnotationsSetAction} for the common case of resolving +/// / re-opening or re-anchoring an annotation without resending its +/// {@link Annotation.entries | entries}. +/// +/// Targets one annotation by its {@link annotationId}. Only the fields present +/// on the action are written; omitted fields leave the corresponding +/// {@link Annotation} property unchanged. The annotation's +/// {@link Annotation.entries | entries}, {@link Annotation.id | id}, and +/// {@link Annotation._meta | _meta} are never touched — dispatch +/// {@link AnnotationsSetAction} to replace those, to clear {@link range} +/// (re-anchor to the whole file), or {@link AnnotationsEntrySetAction} / +/// {@link AnnotationsEntryRemovedAction} to edit individual entries. +/// +/// If {@link annotationId} does not match any current annotation the action is +/// a no-op. +public sealed record AnnotationsUpdatedAction +{ + public ActionType Type { get; init; } + + /// The {@link Annotation.id} of the annotation to update. + public required string AnnotationId { get; init; } + + /// Re-anchors the annotation to the file versions this turn produced. + /// Matches a {@link Turn.id} on the owning session. Omit to leave the + /// current {@link Annotation.turnId} unchanged. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; init; } + + /// Re-anchors the annotation to this file. Omit to leave the current + /// {@link Annotation.resource} unchanged. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Resource { get; init; } + + /// Narrows the annotation to this range within {@link resource}. Omit to + /// leave the current {@link Annotation.range} unchanged; this action cannot + /// clear an existing range — dispatch {@link AnnotationsSetAction} to + /// re-anchor to the whole file. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// Marks the annotation resolved (`true`) or re-opens it (`false`). Omit to + /// leave the current {@link Annotation.resolved} state unchanged. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Resolved { get; init; } +} + +// ─── Partial Summaries (action-discovered) ─────────────────────────── + +/// Partial equivalent of ChatSummary — every field is optional for delta updates. +public sealed record PartialChatSummary +{ + /// Chat URI + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Resource { get; init; } + + /// Chat title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Current chat status (reuses SessionStatus shape) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionStatus? Status { get; init; } + + /// Human-readable description of what the chat is currently doing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; init; } + + /// Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ModifiedAt { get; init; } + + /// Optional per-chat model override (defaults to the session's model) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; init; } + + /// Optional per-chat agent override (defaults to the session's agent) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; init; } + + /// How this chat came into existence + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatOrigin? Origin { get; init; } + + /// How the user can interact with this chat. See {@link ChatInteractivity}. + /// + /// Supports agent-team patterns where worker chats are read-only or hidden. + /// Absence defaults to {@link ChatInteractivity.Full} for backward + /// compatibility. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatInteractivity? Interactivity { get; init; } + + /// Optional per-chat working directory. + /// + /// If absent, the chat inherits + /// {@link SessionSummary.workingDirectory | the session's working directory}. + /// See {@link ChatState.workingDirectory} for usage notes. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; init; } +} + +// ─── 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/activeClientSet"] = typeof(SessionActiveClientSetAction), + ["session/activeClientRemoved"] = typeof(SessionActiveClientRemovedAction), + ["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), + ["session/chatAdded"] = typeof(SessionChatAddedAction), + ["session/chatRemoved"] = typeof(SessionChatRemovedAction), + ["session/chatUpdated"] = typeof(SessionChatUpdatedAction), + ["session/defaultChatChanged"] = typeof(SessionDefaultChatChangedAction), + ["chat/turnStarted"] = typeof(ChatTurnStartedAction), + ["chat/delta"] = typeof(ChatDeltaAction), + ["chat/responsePart"] = typeof(ChatResponsePartAction), + ["chat/toolCallStart"] = typeof(ChatToolCallStartAction), + ["chat/toolCallDelta"] = typeof(ChatToolCallDeltaAction), + ["chat/toolCallReady"] = typeof(ChatToolCallReadyAction), + ["chat/toolCallConfirmed"] = typeof(ChatToolCallConfirmedAction), + ["chat/toolCallComplete"] = typeof(ChatToolCallCompleteAction), + ["chat/toolCallResultConfirmed"] = typeof(ChatToolCallResultConfirmedAction), + ["chat/toolCallContentChanged"] = typeof(ChatToolCallContentChangedAction), + ["chat/turnComplete"] = typeof(ChatTurnCompleteAction), + ["chat/turnCancelled"] = typeof(ChatTurnCancelledAction), + ["chat/error"] = typeof(ChatErrorAction), + ["chat/usage"] = typeof(ChatUsageAction), + ["chat/reasoning"] = typeof(ChatReasoningAction), + ["chat/truncated"] = typeof(ChatTruncatedAction), + ["chat/pendingMessageSet"] = typeof(ChatPendingMessageSetAction), + ["chat/pendingMessageRemoved"] = typeof(ChatPendingMessageRemovedAction), + ["chat/queuedMessagesReordered"] = typeof(ChatQueuedMessagesReorderedAction), + ["chat/inputRequested"] = typeof(ChatInputRequestedAction), + ["chat/inputAnswerChanged"] = typeof(ChatInputAnswerChangedAction), + ["chat/inputCompleted"] = typeof(ChatInputCompletedAction), + ["changeset/statusChanged"] = typeof(ChangesetStatusChangedAction), + ["changeset/fileSet"] = typeof(ChangesetFileSetAction), + ["changeset/fileRemoved"] = typeof(ChangesetFileRemovedAction), + ["changeset/contentChanged"] = typeof(ChangesetContentChangedAction), + ["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), + ["annotations/updated"] = typeof(AnnotationsUpdatedAction), + }, + 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..adcd7a11 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -0,0 +1,1222 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +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 record InitializeParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// 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`). + public required List ProtocolVersions { get; init; } + + /// Unique client identifier + public required string ClientId { get; init; } + + /// URIs to subscribe to during handshake + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? InitialSubscriptions { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Locale { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ClientCapabilities? Capabilities { get; init; } +} + +/// 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 record 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"`). + public required string ProtocolVersion { get; init; } + + /// Current server sequence number + public long ServerSeq { get; init; } + + /// Snapshots for each `initialSubscriptions` URI + public required List Snapshots { get; init; } + + /// Suggested default directory for remote filesystem browsing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultDirectory { get; init; } + + /// 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 `'/'`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CompletionTriggerCharacters { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TelemetryCapabilities? Telemetry { get; init; } +} + +/// 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 record 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? McpApps { get; init; } +} + +/// Re-establishes a dropped connection. The server replays missed actions or +/// provides fresh snapshots. +public sealed record ReconnectParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Client identifier from the original connection + public required string ClientId { get; init; } + + /// Last `serverSeq` the client received + public long LastSeenServerSeq { get; init; } + + /// URIs the client was subscribed to + public required List Subscriptions { get; init; } +} + +/// Reconnect result when the server can replay from the requested sequence. +/// +/// The server MUST include all replayed data in the response. +public sealed record ReconnectReplayResult +{ + /// Discriminant + public ReconnectResultType Type { get; init; } + + /// Missed action envelopes since `lastSeenServerSeq` + public required List Actions { get; init; } + + /// 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. + public required List Missing { get; init; } +} + +/// Reconnect result when the gap exceeds the replay buffer. +public sealed record ReconnectSnapshotResult +{ + /// Discriminant + public ReconnectResultType Type { get; init; } + + /// Fresh snapshots for each subscription + public required List Snapshots { get; init; } +} + +/// 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 record SubscribeParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } +} + +/// Result of the `subscribe` command. +/// +/// `snapshot` is present when the subscribed channel has associated state, and +/// absent for stateless channels. +public sealed record SubscribeResult +{ + /// Snapshot of the subscribed channel's state (omitted for stateless channels) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Snapshot? Snapshot { get; init; } +} + +/// 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 record SessionForkSource +{ + /// URI of the existing session to fork from + public required string Session { get; init; } + + /// Turn ID in the source session; content up to and including this turn's response is copied + public required string TurnId { get; init; } +} + +public sealed record CreateSessionParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Agent provider ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; init; } + + /// Model selection (ID and optional model-specific configuration) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; init; } + + /// Initial custom agent selection for the new session. + /// + /// Omit to start the session with no custom agent selected (provider default). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; init; } + + /// Working directory for the session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionForkSource? Fork { get; init; } + + /// Agent-specific configuration values collected via `resolveSessionConfig`. + /// Keys and values correspond to the schema returned by the server. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; init; } + + /// Eagerly claim an active client role for the new session. + /// + /// When provided, the server initializes the session with this client as an + /// active client, equivalent to dispatching a `session/activeClientSet` + /// action immediately after creation. The `clientId` MUST match the + /// `clientId` the creating client supplied in `initialize`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionActiveClient? ActiveClient { get; init; } +} + +/// Disposes a session and cleans up server-side resources. +/// +/// The server broadcasts a `root/sessionRemoved` notification to all clients. +public sealed record DisposeSessionParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } +} + +/// Identifies a source chat and turn to fork from. +public sealed record ChatForkSource +{ + /// URI of the existing chat to fork from + public required string Chat { get; init; } + + /// Turn ID in the source chat; content up to and including this turn's response is copied + public required string TurnId { get; init; } +} + +/// Creates a new chat within a session. +public sealed record CreateChatParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Chat URI (client-chosen, e.g. `ahp-chat:/<uuid>`). + public required string Chat { get; init; } + + /// Optional initial message for the new chat. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? InitialMessage { get; init; } + + /// Optional per-chat model override. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; init; } + + /// Optional per-chat agent override. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; init; } + + /// Optional source chat and turn to fork from. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatForkSource? Source { get; init; } +} + +/// Disposes a chat and cleans up server-side resources. +public sealed record DisposeChatParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } +} + +/// 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 record ListSessionsParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Optional filter criteria + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Filter { get; init; } +} + +/// Result of the `listSessions` command. +public sealed record ListSessionsResult +{ + /// The list of session summaries. + public required List Items { get; init; } +} + +/// 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 record ResourceReadParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Content URI from a `ContentRef` + public required string Uri { get; init; } + + /// Preferred encoding for the returned data (default: server-chosen) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ContentEncoding? Encoding { get; init; } +} + +/// 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 record ResourceReadResult +{ + /// Content encoded as a string + public required string Data { get; init; } + + /// How `data` is encoded + public ContentEncoding Encoding { get; init; } + + /// Content type (e.g. `"image/png"`, `"text/plain"`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } +} + +/// 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 record ResourceWriteParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Target file URI on the server filesystem + public required string Uri { get; init; } + + /// Content encoded as a string + public required string Data { get; init; } + + /// How `data` is encoded + public ContentEncoding Encoding { get; init; } + + /// Content type (e.g. `"text/plain"`, `"image/png"`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + /// If `true`, the server MUST fail if the file already exists instead of + /// overwriting it. Useful for safe creation of new files. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? CreateOnly { get; init; } + + /// 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}. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceWriteMode? Mode { get; init; } + + /// 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`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Position { get; init; } + + /// 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`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IfMatch { get; init; } +} + +/// Result of the `resourceWrite` command. +/// +/// An empty object on success. +public sealed record 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 record ResourceListParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Directory URI on the server filesystem + public required string Uri { get; init; } +} + +/// Result of the `resourceList` command. +public sealed record ResourceListResult +{ + /// Entries directly contained in the requested directory + public required List Entries { get; init; } +} + +/// Directory entry returned by `resourceList`. +public sealed record DirectoryEntry +{ + /// Base name of the entry + public required string Name { get; init; } + + /// Whether the entry is a file or directory + public required string Type { get; init; } +} + +/// 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 record ResourceCopyParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Source URI to copy from + public required string Source { get; init; } + + /// Destination URI to copy to + public required string Destination { get; init; } + + /// If `true`, the server MUST fail if the destination already exists instead + /// of overwriting it. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? FailIfExists { get; init; } +} + +/// Result of the `resourceCopy` command. +/// +/// An empty object on success. +public sealed record 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 record ResourceDeleteParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// URI of the resource to delete + public required string Uri { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recursive { get; init; } +} + +/// Result of the `resourceDelete` command. +/// +/// An empty object on success. +public sealed record 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 record ResourceMoveParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Source URI to move from + public required string Source { get; init; } + + /// Destination URI to move to + public required string Destination { get; init; } + + /// If `true`, the server MUST fail if the destination already exists instead + /// of overwriting it. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? FailIfExists { get; init; } +} + +/// Result of the `resourceMove` command. +/// +/// An empty object on success. +public sealed record 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 record ResourceResolveParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// URI to resolve + public required string Uri { get; init; } + + /// 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'`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? FollowSymlinks { get; init; } +} + +/// Result of the `resourceResolve` command. +public sealed record ResourceResolveResult +{ + /// Canonical URI after symlink resolution. Equal to the requested URI when + /// `followSymlinks` is `false` or the URI does not traverse a symlink. + public required string Uri { get; init; } + + /// Resource kind. + public ResourceType Type { get; init; } + + /// Size in bytes. Omitted for directories when the provider cannot + /// cheaply compute it. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; init; } + + /// Last-modified time in ISO 8601 format, when known. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Mtime { get; init; } + + /// Creation time in ISO 8601 format, when known. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Ctime { get; init; } + + /// Sniffed MIME type, when known (e.g. `"text/plain"`, `"image/png"`). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + /// Opaque per-provider version token. When present, pass it as + /// {@link ResourceWriteParams.ifMatch} on a subsequent `resourceWrite` to + /// detect concurrent modifications. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Etag { get; init; } +} + +/// 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 record ResourceMkdirParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Directory URI to create (parents created as needed). + public required string Uri { get; init; } +} + +/// Result of the `resourceMkdir` command. +/// +/// An empty object on success. +public sealed record 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 record ResourceRequestParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Whether the caller needs read access to the resource. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Read { get; init; } + + /// Whether the caller needs write access to the resource. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Write { get; init; } +} + +/// Result of the `resourceRequest` command. +/// +/// An empty object on success. +public sealed record 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 record CreateResourceWatchParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// URI to watch. + public required string Uri { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recursive { get; init; } + + /// Glob patterns or paths relative to `uri` to exclude from reporting. + /// Wrapped in `{ items }` for forward compatibility. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Excludes { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Includes { get; init; } +} + +/// Result of the `createResourceWatch` command. +public sealed record 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. + public required string Channel { get; init; } +} + +/// Fetches historical turns for a chat. Used for lazy loading of conversation +/// history. +public sealed record FetchTurnsParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Before { get; init; } + + /// Maximum number of turns to return. Server MAY impose its own upper bound. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Limit { get; init; } +} + +/// Result of the `fetchTurns` command. +public sealed record FetchTurnsResult +{ + /// The requested turns, ordered oldest-first + public required List Turns { get; init; } + + /// Whether more turns exist before the returned range + public bool HasMore { get; init; } +} + +/// Stop receiving updates for a channel. +public sealed record UnsubscribeParams +{ + /// Channel URI to unsubscribe from + public required string Channel { get; init; } +} + +/// 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 record DispatchActionParams +{ + /// Channel URI this action targets + public required string Channel { get; init; } + + /// Client sequence number + public long ClientSeq { get; init; } + + /// The action to dispatch + public required StateAction Action { get; init; } +} + +/// 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 record AuthenticateParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// The protected resource identifier. MUST match a `resource` value from + /// `ProtectedResourceMetadata` declared in `AgentInfo.protectedResources`. + public required string Resource { get; init; } + + /// Bearer token obtained from the resource's authorization server + public required string Token { get; init; } +} + +/// 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 record 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 record CreateTerminalParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Initial owner of the terminal + public required TerminalClaim Claim { get; init; } + + /// Human-readable terminal name + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; init; } + + /// Initial working directory URI + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cwd { get; init; } + + /// Initial terminal width in columns + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Cols { get; init; } + + /// Initial terminal height in rows + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Rows { get; init; } +} + +/// 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 record DisposeTerminalParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } +} + +/// 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 record ResolveSessionConfigParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Agent provider ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; init; } + + /// Working directory for the session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; init; } + + /// Current user-filled configuration values + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; init; } +} + +/// Result of the `resolveSessionConfig` command. +public sealed record ResolveSessionConfigResult +{ + /// JSON Schema describing available configuration properties given the current context + public required SessionConfigSchema Schema { get; init; } + + /// Current configuration values (echoed back with server-resolved defaults applied) + public required Dictionary Values { get; init; } +} + +/// 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 record SessionConfigCompletionsParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Agent provider ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; init; } + + /// Working directory for the session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; init; } + + /// Current user-filled configuration values (provides context for the query) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; init; } + + /// Property id from the schema to query values for + public required string Property { get; init; } + + /// Search filter text (empty or omitted returns default/recent values) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Query { get; init; } +} + +/// Result of the `sessionConfigCompletions` command. +public sealed record SessionConfigCompletionsResult +{ + /// Matching value items + public required List Items { get; init; } +} + +/// A single value item returned by `sessionConfigCompletions`. +public sealed record SessionConfigValueItem +{ + /// The value to store in config + public required string Value { get; init; } + + /// Human-readable display label + public required string Label { get; init; } + + /// Optional secondary description + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } +} + +/// 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 record CompletionsParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// What kind of completion is being requested. + public CompletionItemKind Kind { get; init; } + + /// The complete text of the input being completed (e.g. the full user + /// message text typed so far). + public required string Text { get; init; } + + /// The character offset within `text` at which the completion is requested, + /// measured in UTF-16 code units. MUST satisfy `0 <= offset <= text.length`. + public long Offset { get; init; } +} + +/// 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 record CompletionItem +{ + /// The text inserted into the input when this item is accepted. + public required string InsertText { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? RangeStart { get; init; } + + /// The end of the range in the input's `text` that is replaced by + /// `insertText`. See {@link rangeStart}. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? RangeEnd { get; init; } + + /// The attachment associated with this completion item. + public required MessageAttachment Attachment { get; init; } +} + +/// Result of the `completions` command. +public sealed record CompletionsResult +{ + /// The completion items, in the order the server suggests displaying them. + public required List Items { get; init; } +} + +/// 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 record InvokeChangesetOperationParams +{ + /// Channel URI this command targets. + public required string Channel { get; init; } + + /// Matches {@link ChangesetOperation.id} from the changeset's `operations` list. + public required string OperationId { get; init; } + + /// Target of the operation. Required iff the chosen scope is + /// `'resource'` or `'range'`. Omit for changeset-scoped operations. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesetOperationTarget? Target { get; init; } +} + +/// 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 record InvokeChangesetOperationResult +{ + /// Optional human-readable message describing the result. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? Message { get; init; } + + /// Optional follow-up: a URI to open (e.g. a PR), a content ref, etc. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesetOperationFollowUp? FollowUp { get; init; } +} + +/// 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 record ChangesetOperationFollowUp +{ + public required ContentRef Content { get; init; } + + /// When `true`, open in an external handler rather than inline. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? External { get; init; } +} + +// ─── 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 record ChangesetOperationResourceTarget +{ + public string Kind { get; init; } = "resource"; + + public required string Resource { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; init; } +} + +/// Targets a range within a resource. +public sealed record ChangesetOperationRangeTarget +{ + public string Kind { get; init; } = "range"; + + public required string Resource { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; init; } + + public required TextRange Range { get; init; } +} + +/// 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..58774a46 --- /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 + +namespace Microsoft.AgentHostProtocol; + +/// Standard JSON-RPC 2.0 error codes. +public static class JsonRpcErrorCodes +{ + /// Invalid JSON + public const int ParseError = -32700; + /// Not a valid JSON-RPC request + public const int InvalidRequest = -32600; + /// Unknown method name + public const int MethodNotFound = -32601; + /// Invalid method parameters + public const int InvalidParams = -32602; + /// Unspecified server error + public const int InternalError = -32603; +} + +/// AHP application-specific error codes (above the JSON-RPC reserved range). +public static class AhpErrorCodes +{ + /// The referenced session URI does not exist + public const int SessionNotFound = -32001; + /// The requested agent provider is not registered + public const int ProviderNotFound = -32002; + /// A session with the given URI already exists + public const int SessionAlreadyExists = -32003; + /// The operation requires no active turn, but one is in progress + public const int TurnInProgress = -32004; + /// The server cannot speak any of the protocol versions offered by the client in `InitializeParams.protocolVersions`. The `data` field of the JSON-RPC error MAY be an `UnsupportedProtocolVersionErrorData` advertising the protocol versions the server is willing to speak. + public const int UnsupportedProtocolVersion = -32005; + /// The requested content URI does not exist + public const int ContentNotFound = -32006; + /// A command failed because the client has not authenticated for a required protected resource. The `data` field of the JSON-RPC error MUST be an `AuthRequiredErrorData` describing the resources that require authentication. @see {@link /specification/authentication | Authentication} + public const int AuthRequired = -32007; + /// The requested file, folder, or URI does not exist + public const int NotFound = -32008; + /// The client is not permitted to access the requested resource. Servers SHOULD return this when a client attempts to read or browse a path outside the allowed set (e.g. outside the session's working directory or workspace roots). The `data` field of the JSON-RPC error MAY be a `PermissionDeniedErrorData` advertising a `resourceRequest` that, if granted, would unlock the operation. + public const int PermissionDenied = -32009; + /// The target resource already exists and the operation does not allow overwriting (e.g. `resourceWrite` with `createOnly: true`). + public const int AlreadyExists = -32010; + /// An optimistic-concurrency precondition failed. Returned when a request carries a precondition token that no longer matches the receiver's current state — for example, `resourceWrite` with an `ifMatch` etag that has been superseded by a concurrent write. Callers SHOULD re-read the resource (e.g. via `resourceResolve`) and decide whether to retry the operation with the fresh token or surface the conflict to the user. + public const int Conflict = -32011; +} + +/// Detail payload of an AuthRequired (-32007) error. +public sealed record AuthRequiredErrorData +{ + public required List Resources { get; init; } +} + +/// Detail payload of a PermissionDenied (-32009) error. +public sealed record PermissionDeniedErrorData +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceRequestParams? Request { get; init; } +} + +/// Detail payload of an UnsupportedProtocolVersion (-32005) error. +public sealed record UnsupportedProtocolVersionErrorData +{ + public required List SupportedVersions { get; init; } +} 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..d00905ef --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs @@ -0,0 +1,135 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +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 +{ + // jsonrpc diverges from camelCase(JsonRpcVersion); keep the explicit name. + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + public ulong Id { get; set; } + + public string Method { get; set; } = ""; + + [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; + + public ulong Id { get; set; } + + 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; + + public ulong Id { get; set; } + + public JsonRpcErrorObject Error { get; set; } = new(); +} + +/// The standard JSON-RPC 2.0 error object. +public sealed class JsonRpcErrorObject +{ + public int Code { get; set; } + + public string Message { get; set; } = ""; + + [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; + + public string Method { get; set; } = ""; + + [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..193b27a9 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs @@ -0,0 +1,247 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +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 record SessionAddedParams +{ + /// Channel URI this notification belongs to (the root channel) + public required string Channel { get; init; } + + /// Summary of the new session + public required SessionSummary Summary { get; init; } +} + +/// Broadcast to all clients subscribed to the root channel when a session is +/// disposed. +public sealed record SessionRemovedParams +{ + /// Channel URI this notification belongs to (the root channel) + public required string Channel { get; init; } + + /// URI of the removed session + public required string Session { get; init; } +} + +/// 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 record SessionSummaryChangedParams +{ + /// Channel URI this notification belongs to (the root channel) + public required string Channel { get; init; } + + /// URI of the session whose summary changed + public required string Session { get; init; } + + /// 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. + public required PartialSessionSummary Changes { get; init; } +} + +/// 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 record AuthRequiredParams +{ + /// Channel URI this notification belongs to + public required string Channel { get; init; } + + /// The protected resource identifier that requires authentication + public required string Resource { get; init; } + + /// Why authentication is required + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AuthRequiredReason? Reason { get; init; } +} + +/// 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 record OtlpExportLogsParams +{ + /// Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.logs`). + public required string Channel { get; init; } + + /// OTLP/JSON `ExportLogsServiceRequest` value. The top-level field is + /// `resourceLogs: ResourceLogs[]`; nested shapes are defined by + /// opentelemetry-proto and are not redeclared here. + public required Dictionary Payload { get; init; } +} + +/// 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 record OtlpExportTracesParams +{ + /// Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.traces`). + public required string Channel { get; init; } + + /// OTLP/JSON `ExportTraceServiceRequest` value. The top-level field is + /// `resourceSpans: ResourceSpans[]`; nested shapes are defined by + /// opentelemetry-proto and are not redeclared here. + public required Dictionary Payload { get; init; } +} + +/// 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 record OtlpExportMetricsParams +{ + /// Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.metrics`). + public required string Channel { get; init; } + + /// OTLP/JSON `ExportMetricsServiceRequest` value. The top-level field is + /// `resourceMetrics: ResourceMetrics[]`; nested shapes are defined by + /// opentelemetry-proto and are not redeclared here. + public required Dictionary Payload { get; init; } +} + +// ─── Partial Summaries ──────────────────────────────────────────────── + +/// Partial equivalent of SessionSummary — every field is optional for delta updates. +public sealed record PartialSessionSummary +{ + /// Session URI + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Resource { get; init; } + + /// Agent provider ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Provider { get; init; } + + /// Session title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Current session status + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionStatus? Status { get; init; } + + /// Human-readable description of what the session is currently doing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; init; } + + /// Creation timestamp + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? CreatedAt { get; init; } + + /// Last modification timestamp + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ModifiedAt { get; init; } + + /// Server-owned project for this session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProjectInfo? Project { get; init; } + + /// Currently selected model + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; init; } + + /// Currently selected custom agent. + /// + /// Absent (`undefined`) means no custom agent is selected for this session + /// — the session uses the provider's default behavior. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; init; } + + /// The default working directory URI for this session. Individual chats + /// MAY override via {@link ChatSummary.workingDirectory | their own + /// `workingDirectory`}; this field acts as the fallback for any chat that + /// does not. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangesSummary? Changes { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AnnotationsSummary? Annotations { get; init; } + + /// Lightweight server-defined metadata clients may use for the session + /// presentation. The protocol does not interpret these values; producers + /// SHOULD keep the payload small because summaries appear in session lists + /// and session notifications. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} 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..125f716b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -0,0 +1,4718 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +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, +} + +/// Discriminant for {@link ChatOrigin} — how a chat came into existence. +[JsonConverter(typeof(WireEnumConverter))] +public enum ChatOriginKind +{ + /// User created the chat explicitly (e.g. via the host UI). + [WireValue("user")] + User, + /// Forked from an existing chat at a specific turn. + [WireValue("fork")] + Fork, + /// Spawned by a tool call running in another chat (e.g. a sub-agent delegation). + [WireValue("tool")] + Tool, +} + +/// How a user can interact with a chat. +/// +/// - `Full` — user can send messages and watch (default when absent) +/// - `ReadOnly` — user can watch but not send messages (e.g. agent team workers) +/// - `Hidden` — internal worker not shown in UI at all +/// +/// Supports the agent-team pattern where a lead chat is fully interactive and +/// worker chats are read-only (visible for observability) or hidden (internal +/// implementation detail). The harness sets this based on the chat's role; +/// the UI uses it to show appropriate controls. +[JsonConverter(typeof(WireEnumConverter))] +public enum ChatInteractivity +{ + /// User can send messages and watch (default when absent) + [WireValue("full")] + Full, + /// User can watch but not send messages + [WireValue("read-only")] + ReadOnly, + /// Internal worker not shown in UI at all + [WireValue("hidden")] + Hidden, +} + +/// Answer lifecycle state. +[JsonConverter(typeof(WireEnumConverter))] +public enum ChatInputAnswerState +{ + [WireValue("draft")] + Draft, + [WireValue("submitted")] + Submitted, + [WireValue("skipped")] + Skipped, +} + +/// Answer value kind. +[JsonConverter(typeof(WireEnumConverter))] +public enum ChatInputAnswerValueKind +{ + [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 ChatInputQuestionKind +{ + [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 ChatInputResponseKind +{ + [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 MessageOrigin} — identifies who produced a message. +[JsonConverter(typeof(WireEnumConverter))] +public enum MessageKind +{ + /// Sent directly by the user. + [WireValue("user")] + User, + /// Produced by the agent itself rather than the user — for example, an agent + /// that seeds the first message of a chat it spawned. + [WireValue("agent")] + Agent, + /// Produced by a tool rather than the user — for example, a tool that spawns a + /// worker chat whose first message carries a seed prompt. + [WireValue("tool")] + Tool, + /// A system-generated notification rather than a direct user message. + [WireValue("systemNotification")] + SystemNotification, +} + +/// 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, + /// The operation is currently disabled and cannot be invoked. + [WireValue("disabled")] + Disabled, +} + +/// 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 record 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. + public required string Src { get; init; } + + /// Optional MIME type override if the source MIME type is missing or generic. + /// For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Sizes { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Theme { get; init; } +} + +/// 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 record ProtectedResourceMetadata +{ + /// REQUIRED. The protected resource's resource identifier, a URL using the + /// `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + public required string Resource { get; init; } + + /// OPTIONAL. Human-readable name of the protected resource. + [JsonPropertyName("resource_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceName { get; init; } + + /// OPTIONAL. JSON array of OAuth authorization server identifier URLs. + [JsonPropertyName("authorization_servers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AuthorizationServers { get; init; } + + /// OPTIONAL. URL of the protected resource's JWK Set document. + [JsonPropertyName("jwks_uri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? JwksUri { get; init; } + + /// RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. + [JsonPropertyName("scopes_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ScopesSupported { get; init; } + + /// OPTIONAL. JSON array of Bearer Token presentation methods supported. + [JsonPropertyName("bearer_methods_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? BearerMethodsSupported { get; init; } + + /// OPTIONAL. JSON array of JWS signing algorithms supported. + [JsonPropertyName("resource_signing_alg_values_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ResourceSigningAlgValuesSupported { get; init; } + + /// OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. + [JsonPropertyName("resource_encryption_alg_values_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ResourceEncryptionAlgValuesSupported { get; init; } + + /// OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. + [JsonPropertyName("resource_encryption_enc_values_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ResourceEncryptionEncValuesSupported { get; init; } + + /// OPTIONAL. URL of human-readable documentation for the resource. + [JsonPropertyName("resource_documentation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceDocumentation { get; init; } + + /// OPTIONAL. URL of the resource's data-usage policy. + [JsonPropertyName("resource_policy_uri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourcePolicyUri { get; init; } + + /// OPTIONAL. URL of the resource's terms of service. + [JsonPropertyName("resource_tos_uri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceTosUri { get; init; } + + /// 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`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } +} + +/// Global state shared with every client subscribed to `ahp-root://`. +public sealed class RootState +{ + /// Available agent backends and their models + public required List Agents { get; set; } + + /// Number of active (non-disposed) sessions on the server + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ActiveSessions { get; set; } + + /// Known terminals on the server. Subscribe to individual terminal URIs for full state. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Terminals { get; set; } + + /// Agent host configuration schema and current values + [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 + public required ConfigSchema Schema { get; set; } + + /// Current configuration values + public required Dictionary Values { get; set; } +} + +public sealed record AgentInfo +{ + /// Agent provider ID (e.g. `'copilot'`) + public required string Provider { get; init; } + + /// Human-readable name + public required string DisplayName { get; init; } + + /// Description string + public required string Description { get; init; } + + /// Available models for this agent + public required List Models { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ProtectedResources { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Customizations { get; init; } +} + +public sealed record SessionModelInfo +{ + /// Model identifier + public required string Id { get; init; } + + /// Provider this model belongs to + public required string Provider { get; init; } + + /// Human-readable model name + public required string Name { get; init; } + + /// Maximum context window size + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? MaxContextWindow { get; init; } + + /// Maximum number of output tokens the model can generate + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? MaxOutputTokens { get; init; } + + /// Maximum number of prompt (input) tokens the model accepts + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? MaxPromptTokens { get; init; } + + /// Whether the model supports vision + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SupportsVision { get; init; } + + /// Policy configuration state + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PolicyState? PolicyState { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigSchema? ConfigSchema { get; init; } + + /// 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; init; } +} + +/// 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 record ModelSelection +{ + /// Model identifier + public required string Id { get; init; } + + /// Model-specific configuration values. Values are JSON primitives: most + /// pickers produce strings, but some (e.g. a numeric context-size picker) + /// produce numbers or booleans, which are carried through as-is. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Config { get; init; } +} + +/// 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 record AgentSelection +{ + /// Stable agent URI (matches an {@link AgentCustomization.uri}). + public required string Uri { get; init; } +} + +/// 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 record ConfigPropertySchema +{ + /// JSON Schema: property type + public required string Type { get; init; } + + /// JSON Schema: human-readable label for the property + public required string Title { get; init; } + + /// JSON Schema: description / tooltip + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// JSON Schema: default value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Default { get; init; } + + /// JSON Schema: allowed values. May be primitives of any JSON type. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Enum { get; init; } + + /// Display extension: human-readable label per enum value (parallel array) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumLabels { get; init; } + + /// Display extension: description per enum value (parallel array) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumDescriptions { get; init; } + + /// JSON Schema: when `true`, the property is displayed but cannot be modified by the user + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReadOnly { get; init; } + + /// JSON Schema: schema for array items (used when `type` is `'array'`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigPropertySchema? Items { get; init; } + + /// JSON Schema: property descriptors for object properties (used when `type` is `'object'`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Properties { get; init; } + + /// JSON Schema: list of required property ids (used when `type` is `'object'`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; init; } + + /// JSON Schema: schema for additional properties not listed in `properties` (used when `type` is `'object'`). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigPropertySchema? AdditionalProperties { get; init; } +} + +/// A JSON Schema object describing available configuration properties. +/// +/// This is the generic base type. See {@link SessionConfigSchema} for +/// session-specific usage. +public sealed record ConfigSchema +{ + /// JSON Schema: always `'object'` + public required string Type { get; init; } + + /// JSON Schema: property descriptors keyed by property id + public required Dictionary Properties { get; init; } + + /// JSON Schema: list of required property ids + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; init; } +} + +/// 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 record PendingMessage +{ + /// Unique identifier for this pending message + public required string Id { get; init; } + + /// The message that will start the next turn + public required Message Message { get; init; } +} + +/// Lightweight catalog entry for a chat, carried in +/// {@link SessionState.chats | `SessionState.chats`}. The full conversation +/// lives in {@link ChatState}, which inlines (denormalizes) every field below. +public sealed class ChatSummary +{ + /// Chat URI + public required string Resource { get; set; } + + /// Chat title + public required string Title { get; set; } + + /// Current chat status (reuses SessionStatus shape) + public SessionStatus Status { get; set; } + + /// Human-readable description of what the chat is currently doing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; set; } + + /// Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) + public required string ModifiedAt { get; set; } + + /// Optional per-chat model override (defaults to the session's model) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; set; } + + /// Optional per-chat agent override (defaults to the session's agent) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } + + /// How this chat came into existence + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatOrigin? Origin { get; set; } + + /// How the user can interact with this chat. See {@link ChatInteractivity}. + /// + /// Supports agent-team patterns where worker chats are read-only or hidden. + /// Absence defaults to {@link ChatInteractivity.Full} for backward + /// compatibility. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatInteractivity? Interactivity { get; set; } + + /// Optional per-chat working directory. + /// + /// If absent, the chat inherits + /// {@link SessionSummary.workingDirectory | the session's working directory}. + /// See {@link ChatState.workingDirectory} for usage notes. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } +} + +/// Full state for a single chat, loaded when a client subscribes to the chat's +/// URI. +/// +/// The lightweight catalog representation of a chat is {@link ChatSummary}, +/// carried in {@link SessionState.chats | `SessionState.chats`}. `ChatState` +/// **denormalizes** every {@link ChatSummary} field directly onto itself so +/// subscribers receive one flat object instead of having to merge a nested +/// `summary` sub-object. Producers MUST keep the two representations +/// consistent: any change to the inlined fields below SHOULD also be +/// announced on the parent session via the matching +/// {@link SessionChatUpdatedAction | `session/chatUpdated`} action. +public sealed class ChatState +{ + /// Chat URI + public required string Resource { get; set; } + + /// Chat title + public required string Title { get; set; } + + /// Current chat status (reuses SessionStatus shape) + public SessionStatus Status { get; set; } + + /// Human-readable description of what the chat is currently doing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; set; } + + /// Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) + public required string ModifiedAt { get; set; } + + /// Optional per-chat model override (defaults to the session's model) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelSelection? Model { get; set; } + + /// Optional per-chat agent override (defaults to the session's agent) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } + + /// How this chat came into existence + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatOrigin? Origin { get; set; } + + /// How the user can interact with this chat. See {@link ChatInteractivity}. + /// + /// Supports agent-team patterns where worker chats are read-only or hidden. + /// Absence defaults to {@link ChatInteractivity.Full} for backward + /// compatibility. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatInteractivity? Interactivity { get; set; } + + /// Optional per-chat working directory. + /// + /// If absent, the chat inherits + /// {@link SessionSummary.workingDirectory | the session's working directory}. + /// Hosts MAY override this for individual chats — for example, to give a + /// subordinate chat its own git worktree so multiple chats in a session can + /// make independent edits that the orchestrator later merges back. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkingDirectory { get; set; } + + /// Completed turns + public required List Turns { get; set; } + + /// Currently in-progress turn + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ActiveTurn? ActiveTurn { get; set; } + + /// Message to inject into the current turn at a convenient point + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PendingMessage? SteeringMessage { get; set; } + + /// Messages to send automatically as new turns after the current turn finishes + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? QueuedMessages { get; set; } + + /// Requests for user input that are currently blocking or informing chat progress + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? InputRequests { get; set; } + + /// Additional provider-specific metadata for this chat. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// A choice in a select-style question. +public sealed record ChatInputOption +{ + /// Stable option identifier; for MCP enum values this is the enum string + public required string Id { get; init; } + + /// Display label + public required string Label { get; init; } + + /// Optional secondary text + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Whether this option is the recommended/default choice + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recommended { get; init; } +} + +/// Text question within a chat input request. +public sealed record ChatInputTextQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public ChatInputQuestionKind Kind { get; init; } + + /// Format hint for text questions, such as `email`, `uri`, `date`, or `date-time` + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Format { get; init; } + + /// Minimum string length + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; init; } + + /// Maximum string length + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; init; } + + /// Default text + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultValue { get; init; } +} + +/// Numeric question within a chat input request. +public sealed record ChatInputNumberQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public ChatInputQuestionKind Kind { get; init; } + + /// Minimum value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Min { get; init; } + + /// Maximum value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Max { get; init; } + + /// Default numeric value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? DefaultValue { get; init; } +} + +/// Boolean question within a chat input request. +public sealed record ChatInputBooleanQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public ChatInputQuestionKind Kind { get; init; } + + /// Default boolean value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DefaultValue { get; init; } +} + +/// Single-select question within a chat input request. +public sealed record ChatInputSingleSelectQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public ChatInputQuestionKind Kind { get; init; } + + /// Options the user may select from + public required List Options { get; init; } + + /// Whether the user may enter text instead of selecting an option + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; init; } +} + +/// Multi-select question within a chat input request. +public sealed record ChatInputMultiSelectQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public ChatInputQuestionKind Kind { get; init; } + + /// Options the user may select from + public required List Options { get; init; } + + /// Whether the user may enter text in addition to selecting options + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; init; } + + /// Minimum selected item count + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; init; } + + /// Maximum selected item count + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; init; } +} + +/// A live request for user input. +/// +/// The server creates or replaces requests with `chat/inputRequested`. +/// Clients sync drafts with `chat/inputAnswerChanged` and complete requests +/// with `chat/inputCompleted`. +public sealed class ChatInputRequest +{ + /// Stable request identifier + public required string Id { get; set; } + + /// Display message for the request as a whole + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; set; } + + /// URL the user should review or open, for URL-style elicitations + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Url { get; set; } + + /// Ordered questions to ask the user + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Questions { get; set; } + + /// Current draft or submitted answers, keyed by question ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; set; } +} + +/// Value captured for one answer. +public sealed record ChatInputTextAnswerValue +{ + public ChatInputAnswerValueKind Kind { get; init; } + + public required string Value { get; init; } +} + +public sealed record ChatInputNumberAnswerValue +{ + public ChatInputAnswerValueKind Kind { get; init; } + + public double Value { get; init; } +} + +public sealed record ChatInputBooleanAnswerValue +{ + public ChatInputAnswerValueKind Kind { get; init; } + + public bool Value { get; init; } +} + +public sealed record ChatInputSelectedAnswerValue +{ + public ChatInputAnswerValueKind Kind { get; init; } + + public required string Value { get; init; } + + /// Free-form text entered instead of selecting an option + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +public sealed record ChatInputSelectedManyAnswerValue +{ + public ChatInputAnswerValueKind Kind { get; init; } + + public required List Value { get; init; } + + /// Free-form text entered in addition to selected options + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +public sealed record ChatInputAnswered +{ + /// Answer state + public ChatInputAnswerState State { get; init; } + + /// Answer value + public required ChatInputAnswerValue Value { get; init; } +} + +public sealed record ChatInputSkipped +{ + /// Answer state + public ChatInputAnswerState State { get; init; } + + /// Free-form reason or value captured while skipping, if any + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +/// Full state for a single session, loaded when a client subscribes to the session's URI. +public sealed class SessionState +{ + /// Lightweight session metadata + public required SessionSummary Summary { get; set; } + + /// Session initialization state + public SessionLifecycle Lifecycle { get; set; } + + /// Error details if creation failed + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? CreationError { get; set; } + + /// Tools provided by the server (agent host) for this session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ServerTools { get; set; } + + /// The clients currently providing tools and interactive capabilities to this + /// session. If multiple tools or customizations are provided by the same + /// active client, an agent host MAY deduplicate them when exposed to a model, + /// with a preference given to the client that started the turn. + /// + /// Membership is host-managed: clients add (or refresh) themselves with + /// `session/activeClientSet`, and the host removes them with + /// `session/activeClientRemoved` when they unsubscribe, disconnect without + /// reconnecting in time, or reconnect without resubscribing to the session. + public required List ActiveClients { get; set; } + + /// Catalog of chats in this session. + public required List Chats { get; set; } + + /// The chat that receives input when the user addresses the session without + /// selecting a specific chat. This is a UI routing hint, not a hierarchy + /// marker — chats remain equal peers at the protocol level. Hosts MAY change + /// this over the session's lifetime. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultChat { get; set; } + + /// Session configuration schema and current values + [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 | `activeClients[].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. + [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. + [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; } +} + +/// A client currently providing tools and interactive capabilities to a session. +/// +/// A session MAY have several active clients at once; entries in +/// {@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD +/// automatically remove an active client when that client disconnects. +public sealed class SessionActiveClient +{ + /// Client identifier (matches `clientId` from `initialize`) + public required string ClientId { get; set; } + + /// Human-readable client name (e.g. `"VS Code"`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayName { get; set; } + + /// Tools this client provides to the session + public required List Tools { get; set; } + + /// 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}. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Customizations { get; set; } +} + +/// Lightweight catalog entry summarizing one session. Surfaced via +/// {@link RootChannelCommands.listSessions | `root/listSessions`} and +/// `root/sessionAdded`/`root/sessionSummaryChanged` notifications. +/// +/// **Aggregation across chats.** Once a session contains more than one chat, +/// several `SessionSummary` fields are derived from the underlying +/// {@link SessionState.chats | chat catalog}. Producers SHOULD follow these +/// rules so clients that only consume the session summary (e.g. a session +/// list) still see meaningful state: +/// +/// - `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` / +/// `Error` — bits 0–4) from the +/// {@link SessionState.defaultChat | default chat} when present, else from +/// the most recently modified chat. **Promote** `InputNeeded` whenever any +/// chat in the session needs input, and **promote** `Error` whenever any +/// chat is in an error state — both override the default-chat bits. The +/// orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped. +/// - `activity`: mirror the activity string of the default chat, or of the +/// chat currently driving the promoted status bits when a non-default chat +/// wins (e.g. the chat that raised `InputNeeded`). +/// - `modifiedAt`: the max of all chats' `modifiedAt`. +/// - `model` / `agent`: the session-level selection. Per-chat overrides are +/// surfaced on individual {@link ChatSummary} entries, not aggregated up. +/// - `workingDirectory`: the session-level **default**. Individual chats MAY +/// override via {@link ChatSummary.workingDirectory}; aggregating these up +/// is meaningless and SHOULD NOT be attempted. +/// - `changes`: optional roll-up across all chats. Producers MAY sum the +/// per-chat changeset stats or report the most expensive chat's stats — +/// whichever is cheaper for the host to compute. +/// +/// Sessions with a single chat trivially satisfy all of the above (the chat's +/// values pass through unchanged). The rules only matter once a session +/// carries multiple chats. +public sealed class SessionSummary +{ + /// Session URI + public required string Resource { get; set; } + + /// Agent provider ID + public required string Provider { get; set; } + + /// Session title + public required string Title { get; set; } + + /// Current session status + public SessionStatus Status { get; set; } + + /// Human-readable description of what the session is currently doing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Activity { get; set; } + + /// Creation timestamp + public long CreatedAt { get; set; } + + /// Last modification timestamp + public long ModifiedAt { get; set; } + + /// Server-owned project for this session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProjectInfo? Project { get; set; } + + /// Currently selected 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AgentSelection? Agent { get; set; } + + /// The default working directory URI for this session. Individual chats + /// MAY override via {@link ChatSummary.workingDirectory | their own + /// `workingDirectory`}; this field acts as the fallback for any chat that + /// does not. + [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. + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AnnotationsSummary? Annotations { get; set; } + + /// Lightweight server-defined metadata clients may use for the session + /// presentation. The protocol does not interpret these values; producers + /// SHOULD keep the payload small because summaries appear in session lists + /// and session notifications. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { 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 record ChangesSummary +{ + /// Total number of inserted lines across all changed files. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Additions { get; init; } + + /// Total number of deleted lines across all changed files. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Deletions { get; init; } + + /// Number of files that have changes. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Files { get; init; } +} + +/// Server-owned project metadata for a session. +public sealed record ProjectInfo +{ + /// Project URI + public required string Uri { get; init; } + + /// Human-readable project name + public required string DisplayName { get; init; } +} + +/// A session configuration property descriptor. +/// +/// Extends the generic {@link ConfigPropertySchema} with session-specific +/// display extensions. +public sealed record SessionConfigPropertySchema +{ + /// JSON Schema: property type + public required string Type { get; init; } + + /// JSON Schema: human-readable label for the property + public required string Title { get; init; } + + /// JSON Schema: description / tooltip + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// JSON Schema: default value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Default { get; init; } + + /// JSON Schema: allowed values. May be primitives of any JSON type. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Enum { get; init; } + + /// Display extension: human-readable label per enum value (parallel array) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumLabels { get; init; } + + /// Display extension: description per enum value (parallel array) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? EnumDescriptions { get; init; } + + /// JSON Schema: when `true`, the property is displayed but cannot be modified by the user + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReadOnly { get; init; } + + /// JSON Schema: schema for array items (used when `type` is `'array'`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigPropertySchema? Items { get; init; } + + /// JSON Schema: property descriptors for object properties (used when `type` is `'object'`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Properties { get; init; } + + /// JSON Schema: list of required property ids (used when `type` is `'object'`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; init; } + + /// JSON Schema: schema for additional properties not listed in `properties` (used when `type` is `'object'`). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfigPropertySchema? AdditionalProperties { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? EnumDynamic { get; init; } + + /// When `true`, the user may change this property after session creation + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SessionMutable { get; init; } +} + +/// A JSON Schema object describing available session configuration metadata. +public sealed record SessionConfigSchema +{ + /// JSON Schema: always `'object'` + public required string Type { get; init; } + + /// JSON Schema: property descriptors keyed by property id + public required Dictionary Properties { get; init; } + + /// JSON Schema: list of required property ids + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Required { get; init; } +} + +/// 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 + public required SessionConfigSchema Schema { get; set; } + + /// Current configuration values + public required Dictionary Values { get; set; } +} + +/// A completed request/response cycle. +public sealed class Turn +{ + /// Turn identifier + public required string Id { get; set; } + + /// The message that initiated the turn + 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. + public required List ResponseParts { get; set; } + + /// Token usage info + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UsageInfo? Usage { get; set; } + + /// How the turn ended + public TurnState State { get; set; } + + /// Error details if state is `'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 + public required string Id { get; set; } + + /// The message that initiated the turn + 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. + public required List ResponseParts { get; set; } + + /// Token usage info + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UsageInfo? Usage { get; set; } +} + +/// Identifies the origin of a {@link Message} — who produced it. For the message +/// that initiates a turn ({@link Turn.message}), this is also the origin of the +/// turn; for steering or queued messages it is just the origin of that message. +public sealed record MessageOrigin +{ + /// The kind of actor that produced the message. + public MessageKind Kind { get; init; } +} + +/// A message that initiates or steers a turn. Messages can originate from the +/// user, the agent, a tool, or be system-generated (see {@link MessageOrigin}). +/// +/// 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 + public required string Text { get; set; } + + /// The origin of the message + public required MessageOrigin Origin { get; set; } + + /// File/selection 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 zero-based position within a textual document. +public sealed record TextPosition +{ + /// Zero-based line number. + public long Line { get; init; } + + /// Zero-based character offset within the line. + public long Character { get; init; } +} + +/// A range within a textual document. +public sealed record TextRange +{ + /// Start position of the range. + public required TextPosition Start { get; init; } + + /// End position of the range. + public required TextPosition End { get; init; } +} + +/// 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 record TextSelection +{ + /// The range covered by the selection. + public required TextRange Range { get; init; } +} + +/// A simple, opaque attachment whose model representation is described by +/// the producer. +public sealed record SimpleMessageAttachment +{ + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + public required string Label { get; init; } + + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; init; } + + /// 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; init; } + + /// Discriminant + public MessageAttachmentKind Type { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ModelRepresentation { get; init; } +} + +/// 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 record MessageEmbeddedResourceAttachment +{ + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + public required string Label { get; init; } + + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; init; } + + /// 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; init; } + + /// Discriminant + public MessageAttachmentKind Type { get; init; } + + /// Base64-encoded binary data + public required string Data { get; init; } + + /// Content MIME type (e.g. `"image/png"`, `"application/pdf"`) + public required string ContentType { get; init; } + + /// Optional selection within the attached textual resource. + /// + /// Only meaningful for textual resources. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextSelection? Selection { get; init; } +} + +/// An attachment that references a resource by URI. The content is not +/// delivered inline; consumers can fetch it via `resourceRead` when needed. +public sealed record MessageResourceAttachment +{ + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + public required string Label { get; init; } + + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; init; } + + /// 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; init; } + + /// Content URI + public required string Uri { get; init; } + + /// Approximate size in bytes + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; init; } + + /// Content MIME type + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + /// Discriminant + public MessageAttachmentKind Type { get; init; } + + /// Optional selection within the referenced textual resource. + /// + /// Only meaningful for textual resources. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextSelection? Selection { get; init; } +} + +/// 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 record MessageAnnotationsAttachment +{ + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + public required string Label { get; init; } + + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DisplayKind { get; init; } + + /// 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; init; } + + /// Discriminant + public MessageAttachmentKind Type { get; init; } + + /// The annotations channel URI (typically `ahp-session:/<uuid>/annotations`). + /// Matches {@link AnnotationsSummary.resource}. + public required string Resource { get; init; } + + /// Specific {@link Annotation.id | annotation ids} to reference. When + /// omitted, the attachment references all annotations on the channel. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AnnotationIds { get; init; } +} + +public sealed class MarkdownResponsePart +{ + /// Discriminant + public ResponsePartKind Kind { get; set; } + + /// Part identifier, used by `chat/delta` to target this part for content appends + public required string Id { get; set; } + + /// Markdown content + public required string Content { get; set; } +} + +/// A reference to large content stored outside the state tree. +public sealed record ContentRef +{ + /// Content URI + public required string Uri { get; init; } + + /// Approximate size in bytes + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; init; } + + /// Content MIME type + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } +} + +/// A content part that's a reference to large content stored outside the state tree. +public sealed record ResourceResponsePart +{ + /// Content URI + public required string Uri { get; init; } + + /// Approximate size in bytes + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; init; } + + /// Content MIME type + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + /// Discriminant + public ResponsePartKind Kind { get; init; } +} + +/// 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 + public ResponsePartKind Kind { get; set; } + + /// Full tool call lifecycle state + public required ToolCallState ToolCall { get; set; } +} + +/// Reasoning/thinking content from the model. +public sealed class ReasoningResponsePart +{ + /// Discriminant + public ResponsePartKind Kind { get; set; } + + /// Part identifier, used by `chat/reasoning` to target this part for content appends + public required string Id { get; set; } + + /// Accumulated reasoning text + public required 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 record SystemNotificationResponsePart +{ + /// Discriminant + public ResponsePartKind Kind { get; init; } + + /// The text of the system notification + public required StringOrMarkdown Content { get; init; } +} + +/// Tool execution result details, available after execution completes. +public sealed record ToolCallResult +{ + /// Whether the tool succeeded + public bool Success { get; init; } + + /// Past-tense description of what the tool did + public required StringOrMarkdown PastTenseMessage { get; init; } + + /// Unstructured result content blocks. + /// + /// This mirrors the `content` field of MCP `CallToolResult`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; init; } + + /// Optional structured result object. + /// + /// This mirrors the `structuredContent` field of MCP `CallToolResult`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? StructuredContent { get; init; } + + /// Error details if the tool failed + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Error { get; init; } +} + +/// 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 record ConfirmationOption +{ + /// Unique identifier for the option, returned in the confirmed action + public required string Id { get; init; } + + /// Human-readable label displayed to the user + public required string Label { get; init; } + + /// Whether this option represents an approval or denial + public ConfirmationOptionKind Kind { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Group { get; init; } +} + +/// LM is streaming the tool call parameters. +public sealed class ToolCallStreamingState +{ + /// Unique tool call identifier + public required string ToolCallId { get; set; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; set; } + + /// Human-readable tool name + public required string DisplayName { get; set; } + + /// Reference to the contributor of the tool being called. + [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; } + + public ToolCallStatus Status { get; set; } + + /// Partial parameters accumulated so far + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PartialInput { get; set; } + + /// Progress message shown while parameters are streaming + [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 record ToolCallPendingConfirmationState +{ + /// Unique tool call identifier + public required string ToolCallId { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } + + /// 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; init; } + + /// Message describing what the tool will do + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + public ToolCallStatus Status { get; init; } + + /// Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ConfirmationTitle { get; init; } + + /// File edits that this tool call will perform, for preview before confirmation + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Edits { get; init; } + + /// Whether the agent host allows the client to edit the tool's input parameters before confirming + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Editable { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; init; } +} + +/// Tool is actively executing. +public sealed class ToolCallRunningState +{ + /// Unique tool call identifier + public required string ToolCallId { get; set; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; set; } + + /// Human-readable tool name + public required string DisplayName { get; set; } + + /// Reference to the contributor of the tool being called. + [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 + public required StringOrMarkdown InvocationMessage { get; set; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; set; } + + public ToolCallStatus Status { get; set; } + + /// How the tool was confirmed for execution + public ToolCallConfirmationReason Confirmed { get; set; } + + /// The confirmation option the user selected, if confirmation options were provided + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; set; } +} + +/// Tool finished executing, waiting for client to approve the result. +public sealed record ToolCallPendingResultConfirmationState +{ + /// Unique tool call identifier + public required string ToolCallId { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } + + /// 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; init; } + + /// Message describing what the tool will do + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + /// Whether the tool succeeded + public bool Success { get; init; } + + /// Past-tense description of what the tool did + public required StringOrMarkdown PastTenseMessage { get; init; } + + /// Unstructured result content blocks. + /// + /// This mirrors the `content` field of MCP `CallToolResult`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; init; } + + /// Optional structured result object. + /// + /// This mirrors the `structuredContent` field of MCP `CallToolResult`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? StructuredContent { get; init; } + + /// Error details if the tool failed + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Error { get; init; } + + public ToolCallStatus Status { get; init; } + + /// How the tool was confirmed for execution + public ToolCallConfirmationReason Confirmed { get; init; } + + /// The confirmation option the user selected, if confirmation options were provided + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; init; } +} + +/// Tool completed successfully or with an error. +public sealed record ToolCallCompletedState +{ + /// Unique tool call identifier + public required string ToolCallId { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } + + /// 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; init; } + + /// Message describing what the tool will do + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + /// Whether the tool succeeded + public bool Success { get; init; } + + /// Past-tense description of what the tool did + public required StringOrMarkdown PastTenseMessage { get; init; } + + /// Unstructured result content blocks. + /// + /// This mirrors the `content` field of MCP `CallToolResult`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; init; } + + /// Optional structured result object. + /// + /// This mirrors the `structuredContent` field of MCP `CallToolResult`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? StructuredContent { get; init; } + + /// Error details if the tool failed + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Error { get; init; } + + public ToolCallStatus Status { get; init; } + + /// How the tool was confirmed for execution + public ToolCallConfirmationReason Confirmed { get; init; } + + /// The confirmation option the user selected, if confirmation options were provided + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; init; } +} + +/// Tool call was cancelled before execution. +public sealed record ToolCallCancelledState +{ + /// Unique tool call identifier + public required string ToolCallId { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } + + /// 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; init; } + + /// Message describing what the tool will do + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + public ToolCallStatus Status { get; init; } + + /// Why the tool was cancelled + public ToolCallCancellationReason Reason { get; init; } + + /// Optional message explaining the cancellation + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; init; } + + /// What the user suggested doing instead + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; init; } + + /// The confirmation option the user selected, if confirmation options were provided + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ConfirmationOption? SelectedOption { get; init; } +} + +/// Describes a tool available in a session, provided by either the server or the active client. +public sealed record ToolDefinition +{ + /// Unique tool identifier + public required string Name { get; init; } + + /// Human-readable display name + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Description of what the tool does + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// JSON Schema defining the expected input parameters. + /// + /// Optional because client-provided tools may not have formal schemas. + /// Mirrors MCP `Tool.inputSchema`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? InputSchema { get; init; } + + /// JSON Schema defining the structure of the tool's output. + /// + /// Mirrors MCP `Tool.outputSchema`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? OutputSchema { get; init; } + + /// Behavioral hints about the tool. All properties are advisory. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolAnnotations? Annotations { get; init; } + + /// Additional provider-specific metadata. + /// + /// Mirrors the MCP `_meta` convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// 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 record ToolAnnotations +{ + /// Alternate human-readable title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Tool does not modify its environment (default: false) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReadOnlyHint { get; init; } + + /// Tool may perform destructive updates (default: true) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DestructiveHint { get; init; } + + /// Repeated calls with the same arguments have no additional effect (default: false) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IdempotentHint { get; init; } + + /// Tool may interact with external entities (default: true) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? OpenWorldHint { get; init; } +} + +/// Text content in a tool result. +/// +/// Mirrors MCP `TextContent`. +public sealed record ToolResultTextContent +{ + public ToolResultContentType Type { get; init; } + + /// The text content + public required string Text { get; init; } +} + +/// Base64-encoded binary content embedded in a tool result. +/// +/// Mirrors MCP `EmbeddedResource` for inline binary data. +public sealed record ToolResultEmbeddedResourceContent +{ + public ToolResultContentType Type { get; init; } + + /// Base64-encoded data + public required string Data { get; init; } + + /// Content type (e.g. `"image/png"`, `"application/pdf"`) + public required string ContentType { get; init; } +} + +/// A reference to a resource stored outside the tool result. +/// +/// Wraps {@link ContentRef} for lazy-loading large results. +public sealed record ToolResultResourceContent +{ + /// Content URI + public required string Uri { get; init; } + + /// Approximate size in bytes + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeHint { get; init; } + + /// Content MIME type + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + + public ToolResultContentType Type { get; init; } +} + +/// Describes a file modification performed by a tool. +public sealed record ToolResultFileEditContent +{ + /// The file state before the edit. Absent for file creations or for in-place file edits. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Before { get; init; } + + /// The file state after the edit. Absent for file deletions. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? After { get; init; } + + /// Optional diff display metadata + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Diff { get; init; } + + public ToolResultContentType Type { get; init; } +} + +/// 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 record ToolResultTerminalContent +{ + public ToolResultContentType Type { get; init; } + + /// Terminal URI (subscribable for full terminal state) + public required string Resource { get; init; } + + /// Display title for the terminal content + public required string Title { get; init; } +} + +/// A reference, embedded in a tool result, to a worker chat spawned by the tool +/// call (a sub-agent delegation), referenced by a chat URI (`ahp-chat:/...`). +/// +/// This is the spawning tool call's forward view of the worker. The worker chat +/// records the same edge in reverse via its {@link ChatOrigin} (`kind: 'tool'`), +/// whose `toolCallId` identifies the tool call that emitted this content. +public sealed record ToolResultSubagentContent +{ + public ToolResultContentType Type { get; init; } + + /// Worker chat URI (subscribable for full chat state) + public required string Resource { get; init; } + + /// Display title for the subagent + public required string Title { get; init; } + + /// Internal agent name + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AgentName { get; init; } + + /// Human-readable description of the subagent's task + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } +} + +/// Container is being loaded by the host. +public sealed record CustomizationLoadingState +{ + public CustomizationLoadStatus Kind { get; init; } +} + +/// Container loaded successfully. +public sealed record CustomizationLoadedState +{ + public CustomizationLoadStatus Kind { get; init; } +} + +/// Container partially loaded but has warnings. +public sealed record CustomizationDegradedState +{ + public CustomizationLoadStatus Kind { get; init; } + + /// Human-readable description of the warning. + public required string Message { get; init; } +} + +/// Container failed to load. +public sealed record CustomizationErrorState +{ + public CustomizationLoadStatus Kind { get; init; } + + /// Human-readable error message. + public required string Message { get; init; } +} + +/// 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). + public required 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. + public required string Uri { get; set; } + + /// Human-readable name. + public required string Name { get; set; } + + /// Icons for UI display. + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// Whether this container is currently enabled. + public bool Enabled { get; set; } + + /// `clientId` of the client that contributed this container. Absent for + /// server-originated entries. + [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. + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } + + 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 record ClientPluginCustomization +{ + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + public required string Id { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Human-readable name. + public required string Name { get; init; } + + /// Icons for UI display. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// Whether this container is currently enabled. + public bool Enabled { get; init; } + + /// `clientId` of the client that contributed this container. Absent for + /// server-originated entries. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClientId { get; init; } + + /// Host-reported load state. Absent means the host has not yet reported + /// a load state for this container. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomizationLoadState? Load { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; init; } + + public CustomizationType Type { get; init; } + + /// Opaque version token used by the host to detect changes. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Nonce { get; init; } +} + +/// 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). + public required 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. + public required string Uri { get; set; } + + /// Human-readable name. + public required string Name { get; set; } + + /// Icons for UI display. + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + /// Whether this container is currently enabled. + public bool Enabled { get; set; } + + /// `clientId` of the client that contributed this container. Absent for + /// server-originated entries. + [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. + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } + + public CustomizationType Type { get; set; } + + /// Which child customization type this directory holds. + public CustomizationType Contents { get; set; } + + /// Whether clients may write into this directory. + 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 record AgentCustomization +{ + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + public required string Id { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Human-readable name. + public required string Name { get; init; } + + /// Icons for UI display. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + public CustomizationType Type { get; init; } + + /// Short description of what the agent specializes in and when to + /// invoke it. Sourced from the agent file's frontmatter `description`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Additional provider-specific metadata for this custom agent. + /// + /// Mirrors the MCP `_meta` convention. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// 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 record SkillCustomization +{ + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + public required string Id { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Human-readable name. + public required string Name { get; init; } + + /// Icons for UI display. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + public CustomizationType Type { get; init; } + + /// Short description used for help text and auto-invocation matching. + /// Sourced from the skill's frontmatter `description`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DisableModelInvocation { get; init; } +} + +/// A prompt contributed by a plugin or directory. +public sealed record PromptCustomization +{ + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + public required string Id { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Human-readable name. + public required string Name { get; init; } + + /// Icons for UI display. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + public CustomizationType Type { get; init; } + + /// Short description of what the prompt does. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } +} + +/// 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 record RuleCustomization +{ + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + public required string Id { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Human-readable name. + public required string Name { get; init; } + + /// Icons for UI display. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + public CustomizationType Type { get; init; } + + /// Description of what the rule enforces. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AlwaysApply { get; init; } + + /// Glob patterns the rule applies to. When present, the rule is only + /// active for matching files. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Globs { get; init; } +} + +/// A hook manifest contributed by a plugin or directory. +public sealed record HookCustomization +{ + /// Session-unique opaque identifier. Used by every action that targets a + /// specific customization. Minted by whoever publishes the customization + /// (typically the agent host). + public required string Id { get; init; } + + /// 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. + public required string Uri { get; init; } + + /// Human-readable name. + public required string Name { get; init; } + + /// Icons for UI display. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Icons { get; init; } + + /// 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + public CustomizationType Type { get; init; } +} + +/// 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). + public required 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. + public required string Uri { get; set; } + + /// Human-readable name. + public required string Name { get; set; } + + /// Icons for UI display. + [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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; set; } + + public CustomizationType Type { get; set; } + + /// Whether this MCP server is currently enabled. + public bool Enabled { get; set; } + + /// Current lifecycle state of the MCP server. + 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. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Channel { get; set; } + + /// MCP App support. This property SHOULD be advertised for MCP servers + /// which support apps. + [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 record 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. + public required AhpMcpUiHostCapabilities Capabilities { get; init; } +} + +/// 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 record AhpMcpUiHostCapabilities +{ + /// Producer proxies the MCP `tools/*` methods to the upstream server. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? ServerTools { get; init; } + + /// Producer proxies the MCP `resources/*` methods to the upstream server. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? ServerResources { get; init; } + + /// Producer accepts `notifications/message` log entries from the App via `mcpNotification`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Logging { get; init; } + + /// Producer serves `sampling/createMessage` via `mcpMethodCall`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Sampling { get; init; } +} + +/// Server is registered with the host but has not yet started. +public sealed record McpServerStartingState +{ + public McpServerStatus Kind { get; init; } +} + +/// Server is running and serving requests. +public sealed record McpServerReadyState +{ + public McpServerStatus Kind { get; init; } +} + +/// 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 record McpServerAuthRequiredState +{ + public McpServerStatus Kind { get; init; } + + /// Why authentication is required. + public McpAuthRequiredReason Reason { get; init; } + + /// 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. + public required ProtectedResourceMetadata Resource { get; init; } + + /// 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`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? RequiredScopes { get; init; } + + /// Human-readable hint, typically from the OAuth `error_description`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } +} + +/// Server failed to start, crashed, or otherwise transitioned to a +/// non-recoverable error. Use {@link McpServerStatus.AuthRequired} +/// for authentication failures. +public sealed record McpServerErrorState +{ + public McpServerStatus Kind { get; init; } + + /// Error details. + public required ErrorInfo Error { get; init; } +} + +/// Server has been shut down. The host MAY remove the server from the +/// session entirely shortly after this state. +public sealed record McpServerStoppedState +{ + public McpServerStatus Kind { get; init; } +} + +public sealed record ToolCallClientContributor +{ + public ToolCallContributorKind Kind { get; init; } + + /// 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 `chat/toolCallComplete` with the result. + public required string ClientId { get; init; } +} + +public sealed record ToolCallMcpContributor +{ + public ToolCallContributorKind Kind { get; init; } + + /// Customization ID of the corresponding MCP server in {@link SessionState.customizations}. + public required string CustomizationId { get; init; } +} + +/// 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 record FileEdit +{ + /// The file state before the edit. Absent for file creations or for in-place file edits. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Before { get; init; } + + /// The file state after the edit. Absent for file deletions. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? After { get; init; } + + /// Optional diff display metadata + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Diff { get; init; } +} + +/// Lightweight terminal metadata exposed on the root state. +public sealed record TerminalInfo +{ + /// Terminal URI (subscribable for full terminal state) + public required string Resource { get; init; } + + /// Human-readable terminal title + public required string Title { get; init; } + + /// Who currently holds this terminal + public required TerminalClaim Claim { get; init; } + + /// Process exit code, if the terminal process has exited + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; init; } +} + +/// A terminal claimed by a connected client. +public sealed record TerminalClientClaim +{ + /// Discriminant + public TerminalClaimKind Kind { get; init; } + + /// The `clientId` of the claiming client + public required string ClientId { get; init; } +} + +/// A terminal claimed by a session, optionally scoped to a specific turn or tool call. +public sealed record TerminalSessionClaim +{ + /// Discriminant + public TerminalClaimKind Kind { get; init; } + + /// Session URI that claimed the terminal + public required string Session { get; init; } + + /// Optional turn identifier within the session + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; init; } + + /// Optional tool call identifier within the turn + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolCallId { get; init; } +} + +/// Full state for a single terminal, loaded when a client subscribes to the terminal's URI. +public sealed class TerminalState +{ + /// Human-readable terminal title + public required string Title { get; set; } + + /// Current working directory of the terminal process + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cwd { get; set; } + + /// Terminal width in columns + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Cols { get; set; } + + /// Terminal height in 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. + public required List Content { get; set; } + + /// Process exit code, set when the terminal process exits + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } + + /// Who currently holds this terminal + 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. + [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 +{ + public required string Type { get; set; } + + /// Accumulated VT output. Appended to by `terminal/data` when no command is executing. + public required 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 +{ + public required string Type { get; set; } + + /// Stable id matching the `commandId` on the corresponding + /// `terminal/commandExecuted` and `terminal/commandFinished` actions. + public required string CommandId { get; set; } + + /// The command line submitted to the shell. + public required 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. + public required string Output { get; set; } + + /// Unix timestamp (ms) when execution started, as reported by the server. + public long Timestamp { get; set; } + + /// Whether the command has finished. + public bool IsComplete { get; set; } + + /// Shell exit code. Set at completion. `undefined` if unknown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExitCode { get; set; } + + /// Wall-clock duration in milliseconds. Set at completion. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? DurationMs { get; set; } +} + +public sealed record UsageInfo +{ + /// Input tokens consumed + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? InputTokens { get; init; } + + /// Output tokens generated + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? OutputTokens { get; init; } + + /// Model used + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Model { get; init; } + + /// Tokens read from cache + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? CacheReadTokens { get; init; } + + /// 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; init; } +} + +public sealed record ErrorInfo +{ + /// Error type identifier + public required string ErrorType { get; init; } + + /// Human-readable error message + public required string Message { get; init; } + + /// Stack trace + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Stack { get; init; } + + /// Additional provider-specific metadata for this error. + /// Clients MAY look for well-known optional keys here to provide enhanced UI + /// (e.g. a structured chat fetch error for richer, localized messaging). + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// A point-in-time snapshot of a subscribed resource's state, returned by +/// `initialize`, `reconnect`, and `subscribe`. +public sealed record Snapshot +{ + /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/<uuid>`, or `ahp-chat:/<uuid>`) + public required string Resource { get; init; } + + /// The current state of the resource + public required SnapshotState State { get; init; } + + /// The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. + public long FromSeq { get; init; } +} + +/// 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 record Changeset +{ + /// Human-readable label, e.g. `"Uncommitted Changes"`. + public required string Label { get; init; } + + /// 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. + public required string UriTemplate { get; init; } + + /// Optional longer description. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// 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. + public required string ChangeKind { get; init; } +} + +/// 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. + public ChangesetStatus Status { get; set; } + + /// Present iff `status === ChangesetStatus.Error`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorInfo? Error { get; set; } + + /// Files in this changeset, keyed by {@link ChangesetFile.id}. + public required List Files { get; set; } + + /// Operations the client may invoke against this changeset. Omit when no + /// operations are available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Operations { get; set; } +} + +/// One file entry within a {@link ChangesetState}. +public sealed record ChangesetFile +{ + /// Stable identifier within the changeset. Typically `after.uri` + /// (or `before.uri` for deletions). + public required string Id { get; init; } + + /// Reuses the existing {@link FileEdit} shape. Clients derive line + /// additions, deletions, and rename/create/delete semantics from this. + public required FileEdit Edit { get; init; } + + /// 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; init; } +} + +/// 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. + public required string Id { get; set; } + + /// Human-readable button/menu label. + public required string Label { get; set; } + + /// Optional longer description shown on hover or in tooltips. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + /// Where this operation can be invoked. + public required List Scopes { get; set; } + + /// 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). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? Confirmation { get; set; } + + /// Optional generic icon hint, e.g. `"check"`, `"trash"`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Icon { get; set; } + + /// Optional group identifier, used to group related operations together. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Group { 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`. + public ChangesetOperationStatus Status { get; set; } + + /// Cause of failure. Present iff + /// `status === ChangesetOperationStatus.Error`; otherwise omitted. + [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 record 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). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Logs { get; init; } + + /// Channel URI for OTLP spans (`otlp/exportTraces` notifications). No + /// template variables are defined by this protocol version. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Traces { get; init; } + + /// Channel URI for OTLP metric data points (`otlp/exportMetrics` + /// notifications). No template variables are defined by this protocol + /// version. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Metrics { get; init; } +} + +/// 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 record 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. + public required string Root { get; init; } + + /// `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). + public bool Recursive { get; init; } + + /// Optional glob patterns or paths relative to `root` to exclude from + /// change reporting. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Excludes { get; init; } + + /// Optional glob patterns or paths relative to `root` to restrict + /// change reporting to. Omit to report every change under `root` + /// subject to `excludes`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Includes { get; init; } +} + +/// A single change observed by a resource watcher. +public sealed record ResourceChange +{ + /// The URI of the resource that changed. + public required string Uri { get; init; } + + /// The kind of change observed. + public ResourceChangeType Type { get; init; } +} + +/// 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 record 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. + public required string Resource { get; init; } + + /// Total number of {@link Annotation} entries in the channel. + public long AnnotationCount { get; init; } + + /// Total number of {@link AnnotationEntry} entries across every annotation. + public long EntryCount { get; init; } +} + +/// Full state for a session's annotations channel, returned when a client +/// subscribes to an `ahp-session:/<uuid>/annotations` URI. +public sealed record AnnotationsState +{ + /// Annotations in this channel, keyed by {@link Annotation.id}. + public required List Annotations { get; init; } +} + +/// 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 record Annotation +{ + /// Stable identifier within the annotations channel. Assigned by the client + /// that dispatches the creating {@link AnnotationsSetAction}. + public required string Id { get; init; } + + /// Turn that produced the file versions this annotation is anchored to. + /// Matches a {@link Turn.id} on the owning session. + public required string TurnId { get; init; } + + /// The file the annotation is anchored to. + public required string Resource { get; init; } + + /// Range within {@link resource} the annotation is anchored to. When + /// omitted the annotation is anchored to the entire file. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextRange? Range { get; init; } + + /// 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 AnnotationsUpdatedAction} carrying + /// the updated flag (or an {@link AnnotationsSetAction} when replacing the + /// whole annotation). + public bool Resolved { get; init; } + + /// Entries in this annotation, in dispatch order (oldest first). MUST + /// contain at least one entry. + public required List Entries { get; init; } + + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// A single entry within an {@link Annotation}. +public sealed record AnnotationEntry +{ + /// Stable identifier within the enclosing annotation. Assigned by the client + /// that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + /// {@link AnnotationsSetAction}) introducing the entry. + public required string Id { get; init; } + + /// Entry body. A bare `string` is rendered as plain text; pass + /// `{ markdown: "…" }` to opt into Markdown rendering. See + /// {@link StringOrMarkdown}. + public required StringOrMarkdown Text { get; init; } + + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +// ─── Hand-written State Types ───────────────────────────────────────── + +[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, +} + +/// A choice in a select-style question. +public sealed record SessionInputOption +{ + /// Stable option identifier; for MCP enum values this is the enum string + public required string Id { get; init; } + + /// Display label + public required string Label { get; init; } + + /// Optional secondary text + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Whether this option is the recommended/default choice + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recommended { get; init; } +} + +/// Value captured for one answer. +public sealed record SessionInputTextAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public required string Value { get; init; } +} + +public sealed record SessionInputNumberAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public double Value { get; init; } +} + +public sealed record SessionInputBooleanAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public bool Value { get; init; } +} + +public sealed record SessionInputSelectedAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public required string Value { get; init; } + + /// Free-form text entered instead of selecting an option + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +public sealed record SessionInputSelectedManyAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public required List Value { get; init; } + + /// Free-form text entered in addition to selected options + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +public sealed record SessionInputAnswered +{ + /// Answer state + public SessionInputAnswerState State { get; init; } + + /// Answer value + public required SessionInputAnswerValue Value { get; init; } +} + +public sealed record SessionInputSkipped +{ + /// Answer state + public SessionInputAnswerState State { get; init; } + + /// Free-form reason or value captured while skipping, if any + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +/// Text question within a session input request. +public sealed record SessionInputTextQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Format hint for text questions, such as `email`, `uri`, `date`, or `date-time` + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Format { get; init; } + + /// Minimum string length + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; init; } + + /// Maximum string length + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; init; } + + /// Default text + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultValue { get; init; } +} + +/// Numeric question within a session input request. +public sealed record SessionInputNumberQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Minimum value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Min { get; init; } + + /// Maximum value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Max { get; init; } + + /// Default numeric value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? DefaultValue { get; init; } +} + +/// Boolean question within a session input request. +public sealed record SessionInputBooleanQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Default boolean value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DefaultValue { get; init; } +} + +/// Single-select question within a session input request. +public sealed record SessionInputSingleSelectQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Options the user may select from + public required List Options { get; init; } + + /// Whether the user may enter text instead of selecting an option + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; init; } +} + +/// Multi-select question within a session input request. +public sealed record SessionInputMultiSelectQuestion +{ + /// Stable question identifier used as the key in `answers` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Options the user may select from + public required List Options { get; init; } + + /// Whether the user may enter text in addition to selecting options + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; init; } + + /// Minimum selected item count + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; init; } + + /// Maximum selected item count + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; init; } +} + +/// 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 + public required string Id { get; set; } + + /// Display message for the request as a whole + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; set; } + + /// URL the user should review or open, for URL-style elicitations + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Url { get; set; } + + /// Ordered questions to ask the user + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Questions { get; set; } + + /// Current draft or submitted answers, keyed by question ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { 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) + { + } +} + +/// ChatInputQuestion is one question within a chat input request. +[JsonConverter(typeof(ChatInputQuestionConverter))] +public sealed class ChatInputQuestion : AhpUnion +{ + /// Creates an empty ChatInputQuestion (no active variant). + public ChatInputQuestion() { } + + /// Creates a ChatInputQuestion wrapping the given variant value. + public ChatInputQuestion(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ChatInputQuestion discriminated union. +internal sealed class ChatInputQuestionConverter : UnionConverter +{ + public ChatInputQuestionConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["text"] = typeof(ChatInputTextQuestion), + ["number"] = typeof(ChatInputNumberQuestion), + ["integer"] = typeof(ChatInputNumberQuestion), + ["boolean"] = typeof(ChatInputBooleanQuestion), + ["single-select"] = typeof(ChatInputSingleSelectQuestion), + ["multi-select"] = typeof(ChatInputMultiSelectQuestion), + }, + allowUnknown: true) + { + } +} + +/// ChatInputAnswerValue is the value captured for one chat input answer. +[JsonConverter(typeof(ChatInputAnswerValueConverter))] +public sealed class ChatInputAnswerValue : AhpUnion +{ + /// Creates an empty ChatInputAnswerValue (no active variant). + public ChatInputAnswerValue() { } + + /// Creates a ChatInputAnswerValue wrapping the given variant value. + public ChatInputAnswerValue(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ChatInputAnswerValue discriminated union. +internal sealed class ChatInputAnswerValueConverter : UnionConverter +{ + public ChatInputAnswerValueConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["text"] = typeof(ChatInputTextAnswerValue), + ["number"] = typeof(ChatInputNumberAnswerValue), + ["boolean"] = typeof(ChatInputBooleanAnswerValue), + ["selected"] = typeof(ChatInputSelectedAnswerValue), + ["selected-many"] = typeof(ChatInputSelectedManyAnswerValue), + }, + allowUnknown: true) + { + } +} + +/// ChatInputAnswer is a draft, submitted, or skipped answer for one chat input question. +[JsonConverter(typeof(ChatInputAnswerConverter))] +public sealed class ChatInputAnswer : AhpUnion +{ + /// Creates an empty ChatInputAnswer (no active variant). + public ChatInputAnswer() { } + + /// Creates a ChatInputAnswer wrapping the given variant value. + public ChatInputAnswer(object? value) : base(value) { } +} + +/// System.Text.Json converter for the ChatInputAnswer discriminated union. +internal sealed class ChatInputAnswerConverter : UnionConverter +{ + public ChatInputAnswerConverter() + : base( + discriminator: "state", + variants: new Dictionary + { + ["draft"] = typeof(ChatInputAnswered), + ["submitted"] = typeof(ChatInputAnswered), + ["skipped"] = typeof(ChatInputSkipped), + }, + 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) + { + } +} + +/// +/// ChatOrigin describes how a chat came into existence. +/// +[JsonConverter(typeof(ChatOriginConverter))] +public sealed class ChatOrigin : AhpUnion +{ + public ChatOrigin() { } + public ChatOrigin(object? value) : base(value) { } +} + +/// Chat was started by a user directly. +public sealed record ChatOriginUser +{ + public string Kind { get; init; } = "user"; +} + +/// Chat was forked from another chat at a specific turn. +public sealed record ChatOriginFork +{ + public string Kind { get; init; } = "fork"; + + public required string Chat { get; init; } + + public required string TurnId { get; init; } +} + +/// Chat was spawned by a tool call in another chat. +public sealed record ChatOriginTool +{ + public string Kind { get; init; } = "tool"; + + public required string Chat { get; init; } + + public required string ToolCallId { get; init; } +} + +/// System.Text.Json converter for the ChatOrigin union. +internal sealed class ChatOriginConverter : UnionConverter +{ + public ChatOriginConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["user"] = typeof(ChatOriginUser), + ["fork"] = typeof(ChatOriginFork), + ["tool"] = typeof(ChatOriginTool), + }, + allowUnknown: true) + { + } +} + +/// +/// SnapshotState is the state payload of a snapshot — root, session, +/// chat, terminal, changeset, resource-watch, or annotations state. Read +/// probes for distinctive fields in an order where no probe shadows another +/// (chat → session → terminal → changeset → resource-watch → annotations → 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; } + + /// Chat state variant, when populated. + public ChatState? Chat { get; set; } + + /// Terminal state variant, when populated. + public TerminalState? Terminal { get; set; } + + /// Changeset state variant, when populated. + public ChangesetState? Changeset { get; set; } + + /// Resource-watch state variant, when populated. + public ResourceWatchState? ResourceWatch { get; set; } + + /// Annotations state variant, when populated. + public AnnotationsState? Annotations { 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("turns", out _)) + { + result.Chat = root.Deserialize(options); + } + else 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 if (root.TryGetProperty("root", out _) && root.TryGetProperty("recursive", out _)) + { + result.ResourceWatch = root.Deserialize(options); + } + else if (root.TryGetProperty("annotations", out _)) + { + result.Annotations = root.Deserialize(options); + } + else + { + result.Root = root.Deserialize(options); + } + return result; + } + + public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) + { + if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, options); return; } + 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.ResourceWatch is not null) { JsonSerializer.Serialize(writer, value.ResourceWatch, options); return; } + if (value.Annotations is not null) { JsonSerializer.Serialize(writer, value.Annotations, 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/Telemetry.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Telemetry.generated.cs new file mode 100644 index 00000000..b670ad0b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Telemetry.generated.cs @@ -0,0 +1,129 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +namespace Microsoft.AgentHostProtocol; + +/// +/// Telemetry NAMES this client emits about its own operation, codegen'd from the +/// client-private registry clients/dotnet/codegen/telemetry/registry.ts so the +/// span / metric / attribute names stay in one place. Names only; the +/// ActivitySource / Meter wiring is hand-written in Telemetry.cs. The registry +/// is structured for promotion to a shared cross-client contract if AHP ever specs +/// one (see issue #239). +/// +public static class AhpTelemetryNames +{ + /// Instrumentation-scope name used for every AHP self-instrumentation span and metric. + public const string Source = "Microsoft.AgentHostProtocol"; + + // ── Span names ── + /// Span covering a single JSON-RPC request, from send until it settles. + public const string RequestSpan = "ahp.request"; + + // ── Metric names ── + /// Messages sent to the host, tagged by ahp.message.kind (request|notification). + public const string MessagesSent = "ahp.client.messages.sent"; + /// Messages received from the host. + public const string MessagesReceived = "ahp.client.messages.received"; + /// Round-trip duration of a JSON-RPC request, tagged by rpc.method and ahp.outcome (ok|error|cancelled|timeout). + public const string RequestDuration = "ahp.client.request.duration"; + /// Requests awaiting a response. + public const string RequestsInFlight = "ahp.client.requests.in_flight"; + /// Subscriptions registered with the client (decremented on unsubscribe or shutdown). + public const string SubscriptionsActive = "ahp.client.subscriptions.active"; + /// Reconnect operations, tagged by outcome. + public const string Reconnects = "ahp.client.reconnects"; + /// Buffered events evicted under back-pressure (drop-oldest), tagged by stream. + public const string EventsDropped = "ahp.client.events.dropped"; + /// Inbound frames that failed to decode and were skipped (protocol resync is the host’s responsibility). + public const string FramesMalformed = "ahp.client.frames.malformed"; + + // ── Metric units ── + /// Unit for the `ahp.client.messages.sent` metric. + public const string MessagesSentUnit = "{message}"; + /// Unit for the `ahp.client.messages.received` metric. + public const string MessagesReceivedUnit = "{message}"; + /// Unit for the `ahp.client.request.duration` metric. + public const string RequestDurationUnit = "ms"; + /// Unit for the `ahp.client.requests.in_flight` metric. + public const string RequestsInFlightUnit = "{request}"; + /// Unit for the `ahp.client.subscriptions.active` metric. + public const string SubscriptionsActiveUnit = "{subscription}"; + /// Unit for the `ahp.client.reconnects` metric. + public const string ReconnectsUnit = "{reconnect}"; + /// Unit for the `ahp.client.events.dropped` metric. + public const string EventsDroppedUnit = "{event}"; + /// Unit for the `ahp.client.frames.malformed` metric. + public const string FramesMalformedUnit = "{frame}"; + + // ── Metric descriptions ── + // Single source for the human-readable instrument description, used both as + // the doc-comment summary above each metric name AND as the runtime + // `description:` passed to Meter.CreateX in Telemetry.cs — so the two cannot drift. + /// Description for the `ahp.client.messages.sent` metric. + public const string MessagesSentDescription = "Messages sent to the host, tagged by ahp.message.kind (request|notification)."; + /// Description for the `ahp.client.messages.received` metric. + public const string MessagesReceivedDescription = "Messages received from the host."; + /// Description for the `ahp.client.request.duration` metric. + public const string RequestDurationDescription = "Round-trip duration of a JSON-RPC request, tagged by rpc.method and ahp.outcome (ok|error|cancelled|timeout)."; + /// Description for the `ahp.client.requests.in_flight` metric. + public const string RequestsInFlightDescription = "Requests awaiting a response."; + /// Description for the `ahp.client.subscriptions.active` metric. + public const string SubscriptionsActiveDescription = "Subscriptions registered with the client (decremented on unsubscribe or shutdown)."; + /// Description for the `ahp.client.reconnects` metric. + public const string ReconnectsDescription = "Reconnect operations, tagged by outcome."; + /// Description for the `ahp.client.events.dropped` metric. + public const string EventsDroppedDescription = "Buffered events evicted under back-pressure (drop-oldest), tagged by stream."; + /// Description for the `ahp.client.frames.malformed` metric. + public const string FramesMalformedDescription = "Inbound frames that failed to decode and were skipped (protocol resync is the host’s responsibility)."; + + // ── Attribute keys ── + /// RPC system identifier (OTel rpc.system); always "jsonrpc" for AHP. + public const string AttrRpcSystem = "rpc.system"; + /// JSON-RPC method name the span/metric is scoped to (OTel rpc.method). + public const string AttrRpcMethod = "rpc.method"; + /// Client-assigned JSON-RPC request id. + public const string AttrRequestId = "ahp.request.id"; + /// Terminal outcome of a request or reconnect (ok|error|cancelled|timeout). + public const string AttrOutcome = "ahp.outcome"; + /// Whether a sent message was a request or a notification. + public const string AttrMessageKind = "ahp.message.kind"; + /// Which event stream a dropped or observed event belongs to. + public const string AttrStream = "ahp.stream"; + + // ── Attribute values ── + /// JSON-RPC — the only RPC system AHP uses. + public const string RpcSystemJsonrpc = "jsonrpc"; + /// The request or reconnect completed successfully. + public const string OutcomeOk = "ok"; + /// The request or reconnect failed with an error response. + public const string OutcomeError = "error"; + /// The request was cancelled before it settled. + public const string OutcomeCancelled = "cancelled"; + /// The request exceeded its configured timeout. + public const string OutcomeTimeout = "timeout"; + /// A JSON-RPC request (expects a response). + public const string MessageKindRequest = "request"; + /// A JSON-RPC notification (fire-and-forget). + public const string MessageKindNotification = "notification"; + /// A per-resource subscription stream. + public const string StreamSubscription = "subscription"; + /// The client-wide event stream. + public const string StreamEvent = "event"; + /// A state-snapshot stream. + public const string StreamState = "state"; + /// A multi-host client's host-event delivery stream. + public const string StreamHostEvent = "host-event"; + /// A multi-host client's host-subscription delivery stream. + public const string StreamHostSubscription = "host-subscription"; + /// A multi-host client's host-resource delivery stream. + public const string StreamHostResource = "host-resource"; + /// A multi-host client's host-snapshot delivery stream. + public const string StreamHostSnapshot = "host-snapshot"; + /// A multi-host client's host-summaries delivery stream. + public const string StreamHostSummaries = "host-summaries"; +} 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..be99df0f --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -0,0 +1,35 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +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.5.0"; + + private static readonly string[] s_supported = + { + "0.5.0", + "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..806c976f --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs @@ -0,0 +1,87 @@ +// 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; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +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. + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + string Serialize(T value); + + /// + /// Serializes directly to a , + /// avoiding the intermediate string + parse (and the + /// undisposed-document leak that JsonDocument.Parse(Serialize(x)).RootElement + /// incurs). The returned element owns its backing memory and is safe to retain. + /// + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + JsonElement SerializeToElement(T value); + + /// Deserializes a JSON string into . + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + T Deserialize(string json); + + /// Deserializes UTF-8 JSON bytes into . + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + T Deserialize(ReadOnlySpan utf8Json); + + /// + /// Deserializes an already-parsed into + /// , binding directly off the element's backing buffer + /// with no intermediate string materialization and no re-tokenize. Symmetric + /// with ; prefer this over + /// Deserialize<T>(element.GetRawText()) on hot paths (inbound + /// notifications, request results) where the element is already in hand. + /// + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + T Deserialize(JsonElement element); + + /// + /// Decodes a transport frame into a , picking the + /// correct variant (request / notification / success / error) from its shape. + /// + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + JsonRpcMessage DecodeMessage(TransportMessage message); + + /// Encodes a into a text transport frame. + [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] + [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] + TransportMessage EncodeMessage(JsonRpcMessage message); +} + +/// +/// Shared / +/// messages for the serializer seam. +/// The default SystemTextJsonAhpSerializer is reflection-based (source-gen +/// is deferred per docs/decisions/serialization.md), so every +/// (de)serialization entry point declares the trim/AOT unsafety on the contract. +/// +internal static class SerializerTrimWarnings +{ + public const string UnreferencedCode = + "JSON (de)serialization here is reflection-based and may reference types that cannot be statically analyzed when trimming. Provide a JsonSerializerContext or preserve the wire types."; + + public const string DynamicCode = + "JSON (de)serialization here is reflection-based and may require runtime code generation under Native AOT. Use System.Text.Json source generation for AOT."; +} 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..c596c6e9 --- /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; init; } + + /// Non-null iff the value was decoded from a bare JSON string. + public string? Plain { get; init; } + + /// 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..588fc8f9 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs @@ -0,0 +1,122 @@ +// 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.Diagnostics.CodeAnalysis; +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; + } + + /// + // root.Deserialize(variantType, options) resolves the payload type at runtime + // from the variant map — genuinely trim/AOT-unsafe. The unsafety is already + // declared on the public contract (IAhpSerializer is [RequiresUnreferencedCode]/ + // [RequiresDynamicCode]); this converter is reachable ONLY through that + // serializer. JsonConverter.Read in the base is not annotated, so the + // requirement cannot be re-declared via [RequiresUnreferencedCode] here (it + // would trip IL2046) — the suppression points back to the contract that owns it. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "Reached only via the [RequiresUnreferencedCode] IAhpSerializer; the base JsonConverter.Read cannot carry the attribute.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Reached only via the [RequiresDynamicCode] IAhpSerializer; the base JsonConverter.Read cannot carry the attribute.")] + 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; + } + + /// + // JsonSerializer.Serialize(writer, inner, inner.GetType(), options) serializes + // by the boxed runtime type — genuinely trim/AOT-unsafe, for the same reason + // as Read above. Declared on the IAhpSerializer contract; suppressed here + // because the base JsonConverter.Write is not annotated. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "Reached only via the [RequiresUnreferencedCode] IAhpSerializer; the base JsonConverter.Write cannot carry the attribute.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Reached only via the [RequiresDynamicCode] IAhpSerializer; the base JsonConverter.Write cannot carry the attribute.")] + 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..92ddd815 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/WireEnum.cs @@ -0,0 +1,85 @@ +// 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.Diagnostics.CodeAnalysis; +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< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T> : JsonConverter + where T : struct, Enum +{ + private static readonly Dictionary s_toWire; + private static readonly Dictionary s_fromWire; + + static WireEnumConverter() + { + var toWire = new Dictionary(); + var fromWire = 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; + toWire[value] = wire; + fromWire[wire] = value; + } + + s_toWire = toWire; + s_fromWire = fromWire; + } + + /// + 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..f9bb0ca1 --- /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. +/// +/// AhpClient uses this only when ClientConfig.KeepAlive +/// 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..46abc3a8 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.WebSockets/AgentHostProtocol.WebSockets.csproj @@ -0,0 +1,37 @@ + + + + net8.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. + + + true + true + + + + + + + + + + + + + + + + diff --git a/clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs b/clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs new file mode 100644 index 00000000..613e6e00 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.WebSockets/WebSocketTransport.cs @@ -0,0 +1,292 @@ +// 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.Buffers; +using System.IO; +using System.Net.WebSockets; +using System.Text; +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; + private int _disposed; + + // 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(); + try + { + options?.ConfigureSocket?.Invoke(ws); + await ws.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + } + catch + { + ws.Dispose(); + throw; + } + 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) + { + ArgumentNullException.ThrowIfNull(ws); + var maxBytes = options?.MaxMessageBytes ?? (32L * 1024 * 1024); + return new WebSocketTransport(ws, maxBytes); + } + + // ── ITransport ──────────────────────────────────────────────────────── + + /// + public async ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (message.Frame == TransportFrame.Text) + { + // Encode into a pooled buffer rather than allocating a fresh + // byte[] per send — SendAsync is the per-message hot path. + var text = message.Text ?? ""; + var rented = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(text.Length)); + try + { + var written = Encoding.UTF8.GetBytes(text, rented); + await _ws.SendAsync( + new ArraySegment(rented, 0, written), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + 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) + { + // Read the first frame. The overwhelmingly common case is a complete + // message in one frame (EndOfMessage on the first receive); that path + // decodes straight from the receive buffer with no MemoryStream and no + // second copy. Only genuinely fragmented messages fall through to the + // accumulating path below. + var result = await ReceiveFrameAsync(cancellationToken).ConfigureAwait(false); + await ThrowIfCloseAsync(result).ConfigureAwait(false); + EnforceSizeCap(accumulated: 0, result.Count); + + if (result.EndOfMessage) + { + // Decode in a non-async helper: a Span (ref struct) cannot live + // across the async method body under C# 12. + return DecodeSingleFrame(result.Count, result.MessageType); + } + + // Fragmented: assemble the remaining frames into a MemoryStream. Note the + // order — copy the just-received bytes out of the buffer FIRST, then grow + // the buffer for the NEXT receive. (The pre-refactor code grew the buffer + // before copying, which discarded the frame it had just read; that path + // only triggered on a fragmented message whose first frame exactly filled + // the 64 KiB buffer, so it went unnoticed.) + var builder = new MemoryStream(); + WebSocketMessageType msgType = result.MessageType; + builder.Write(_receiveBuffer, 0, result.Count); + GrowReceiveBufferIfFull(result); + + while (true) + { + result = await ReceiveFrameAsync(cancellationToken).ConfigureAwait(false); + await ThrowIfCloseAsync(result).ConfigureAwait(false); + EnforceSizeCap(builder.Length, result.Count); + + builder.Write(_receiveBuffer, 0, result.Count); + msgType = result.MessageType; + + if (result.EndOfMessage) + break; + GrowReceiveBufferIfFull(result); + } + + var bytes = builder.ToArray(); + return msgType == WebSocketMessageType.Binary + ? TransportMessage.FromBinary(bytes) + : TransportMessage.FromText(Encoding.UTF8.GetString(bytes)); + } + + // Decodes a complete single-frame message straight from the receive buffer — + // no MemoryStream. Kept non-async so the Span ref struct never crosses + // an await (a C# 13 feature this project does not target). + private TransportMessage DecodeSingleFrame(int count, WebSocketMessageType messageType) + { + ReadOnlySpan span = _receiveBuffer.AsSpan(0, count); + return messageType == WebSocketMessageType.Binary + ? TransportMessage.FromBinary(span.ToArray()) + : TransportMessage.FromText(Encoding.UTF8.GetString(span)); + } + + // Doubles the receive buffer when the last frame filled it, so the next + // ReceiveAsync has more room. Call AFTER copying the frame's bytes out. + private void GrowReceiveBufferIfFull(ValueWebSocketReceiveResult result) + { + if (result.Count == _receiveBuffer.Length) + _receiveBuffer = new byte[_receiveBuffer.Length * 2]; + } + + private async ValueTask ReceiveFrameAsync(CancellationToken cancellationToken) + { + try + { + return await _ws.ReceiveAsync(new Memory(_receiveBuffer), cancellationToken) + .ConfigureAwait(false); + } + catch (WebSocketException ex) + { + // An abnormal drop (no close handshake) is a transport I/O fault, NOT a + // clean close — deliberately not TransportClosedException so the AhpClient + // reader loop's generic catch wraps it in the typed AhpTransportException + // ("io", ...) it surfaces on AhpClient.Error. The richer AhpTransportException + // lives in the client assembly (Microsoft.AgentHostProtocol), which this + // transport package does not (and should not) depend on, so the + // directly-thrown type is IOException (a precise BCL fault type, no longer + // the reserved base Exception) carrying the WebSocketException as its cause. + throw new IOException($"ahp: websocket closed: {ex.Message}", ex); + } + // OperationCanceledException propagates as-is. + } + + private async ValueTask ThrowIfCloseAsync(ValueWebSocketReceiveResult result) + { + if (result.MessageType != WebSocketMessageType.Close) + return; + + // Perform the closing handshake (best effort), then surface a clean close. + try + { + await _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None) + .ConfigureAwait(false); + } + catch { /* best effort */ } + throw new TransportClosedException(); + } + + private void EnforceSizeCap(long accumulated, int incoming) + { + if (_maxMessageBytes > 0 && (accumulated + incoming) > _maxMessageBytes) + throw new TransportClosedException($"inbound message exceeds {_maxMessageBytes} 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() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) + return; + 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..b4872612 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj @@ -0,0 +1,45 @@ + + + + Microsoft.AgentHostProtocol + net8.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). + + true + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs new file mode 100644 index 00000000..cd0f31de --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs @@ -0,0 +1,1255 @@ +// 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.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +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 => 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? parameters); + +// ─── 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 : IAhpClient +{ + // ── 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 Channel _outbound; + + // In-flight request correlation keyed by JSON-RPC id. + private readonly ConcurrentDictionary> _pending = new(); + + // Per-URI subscription fan-out. + private readonly object _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 number of subscriptions currently registered across all URIs — + /// lets the lifecycle tests prove a direct Close()/Dispose() detaches from the + /// registry (not just UnsubscribeAsync). + internal int SubscriptionCount + { + get + { + lock (_subsLock) + { + int n = 0; + foreach (var list in _subscriptions.Values) n += list.Count; + return n; + } + } + } + + internal int EventListenerCount { get { lock (_subsLock) { return _eventListeners.Count; } } } + + internal int StateListenerCount { get { lock (_subsLock) { return _stateListeners.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 = Channel.CreateBounded( + new BoundedChannelOptions(64) + { + FullMode = 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) + { + ArgumentNullException.ThrowIfNull(transport); + var cfg = config ?? ClientConfig.Default; + if (cfg.SubscriptionBufferCapacity <= 0) cfg.SubscriptionBufferCapacity = 256; + return new AhpClient(transport, cfg, serializer ?? SystemTextJsonAhpSerializer.Default); + } + + + // ── 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. + /// + /// The failure that triggered teardown, or null for a clean shutdown. + /// + /// True when called from the keep-alive loop's own failure path (a ping failure). + /// In that case the keep-alive task must NOT be awaited here — it is the very task + /// running this teardown, so awaiting it would deadlock. The loop is already + /// unwinding, so skipping the await is safe. + /// + private async Task ShutdownWithErrorAsync(Exception? cause, bool fromKeepAlive = false) + { + 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. + // Cancel, then await the loop to completion so the still-running loop can no + // longer read `_keepAliveCts.Token`, then dispose the CTS — deterministic + // teardown that disposes the CTS safely. When teardown was *triggered by* the + // keep-alive loop itself (a ping failure flows into ShutdownWithErrorAsync from + // inside `_keepAliveTask` with fromKeepAlive=true), awaiting `_keepAliveTask` + // here would deadlock (the task would await itself), so skip the await in that + // case — the loop is already unwinding and the captured token struct stays + // readable even after the CTS is disposed. + _keepAliveCts.Cancel(); + if (_keepAliveTask is not null && !fromKeepAlive) + { + try { await _keepAliveTask.ConfigureAwait(false); } + catch { /* the loop's own failure path already ran; nothing to observe */ } + } + _keepAliveCts.Dispose(); + + // Dispose the transport (the client owns it): DisposeAsync runs the close + // handshake so any blocked ReceiveAsync unblocks AND releases the transport's + // unmanaged handles (e.g. the ClientWebSocket socket + its send semaphore), + // which CloseAsync alone does not. Bounded to 2s. + using var shutdownCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + await _transport.DisposeAsync().AsTask().WaitAsync(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); + + allListeners = new List(_eventListeners); + _eventListeners.Clear(); + } + // Each Close() runs the subscription's detach hook, which removes it from the + // registry and decrements the active-subscriptions gauge (single teardown path). + 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); + } + // Detach on dispose so an abandoned stream is removed from the fan-out list. + stream.OnClose(() => { lock (_subsLock) { _stateListeners.Remove(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. + // fromKeepAlive: true so the teardown does not await THIS task (which is + // the keep-alive task) — that would deadlock. + await ShutdownWithErrorAsync( + new AhpTransportException("io", $"ahp: keep-alive ping: {ex.Message}", ex), + fromKeepAlive: true).ConfigureAwait(false); + } + } + + // ── Writer loop ─────────────────────────────────────────────────────── + + // The client routes all JSON through the injected IAhpSerializer, whose + // contract is [RequiresUnreferencedCode]/[RequiresDynamicCode] (the default + // SystemTextJsonAhpSerializer is reflection-based). The trim/AOT unsafety is + // declared at that contract; re-declaring it on this internal loop — or + // propagating the attribute up through the constructor / Connect() / the + // entire client + multi-host public surface — is out of scope here, so the + // serializer-call warnings are suppressed at the call site with that + // contract named as the owner. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] + 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 AhpTransportException("io", $"ahp: transport send: {ex.Message}", ex)).ConfigureAwait(false); + return; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await ShutdownWithErrorAsync(new AhpTransportException("io", $"ahp: writer: {ex.Message}", ex)).ConfigureAwait(false); + } + } + + // ── Reader loop ─────────────────────────────────────────────────────── + + // See RunWriterAsync: JSON decode goes through the [RequiresUnreferencedCode]/ + // [RequiresDynamicCode] IAhpSerializer, which owns the trim/AOT-unsafety + // declaration. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] + 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 AhpTransportException("io", $"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. + AhpTelemetry.MalformedFrames.Add(1); + continue; + } + + AhpTelemetry.MessagesReceived.Add(1); + Dispatch(msg); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await ShutdownWithErrorAsync(new AhpTransportException("io", $"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. + /// + // See RunWriterAsync: the handler's result serialize goes through the + // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] + 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); + // SerializeToElement(null) yields a JSON `null` element; no separate + // JsonDocument.Parse("null") (and its undisposed leak) is needed. + JsonElement resultEl = _serializer.SerializeToElement(result); + 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 */ } + } + + // See RunWriterAsync: the per-notification deserialize calls go through the + // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] + 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); } + catch { return; } + FanOut(env.Channel, new SubscriptionEventAction(env)); + break; + } + case "root/sessionAdded": + { + SessionAddedParams p; + try { p = _serializer.Deserialize(paramsEl); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventSessionAdded(p)); + break; + } + case "root/sessionRemoved": + { + SessionRemovedParams p; + try { p = _serializer.Deserialize(paramsEl); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventSessionRemoved(p)); + break; + } + case "root/sessionSummaryChanged": + { + SessionSummaryChangedParams p; + try { p = _serializer.Deserialize(paramsEl); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventSessionSummaryChanged(p)); + break; + } + case "auth/required": + { + AuthRequiredParams p; + try { p = _serializer.Deserialize(paramsEl); } + catch { return; } + FanOut(p.Channel, new SubscriptionEventAuthRequired(p)); + break; + } + } + } + + private void FanOut(string channel, SubscriptionEvent ev) + { + // Snapshot each bucket under the lock (so TrySend runs outside it), but + // only allocate when there is something to fan to — the common per-message + // case has zero matching subscriptions and/or zero event listeners, and a + // shared empty array avoids the per-message List churn on that path. + IReadOnlyList subs; + IReadOnlyList listeners; + lock (_subsLock) + { + subs = _subscriptions.TryGetValue(channel, out var list) && list.Count > 0 + ? new List(list) + : Array.Empty(); + listeners = _eventListeners.Count > 0 + ? new List(_eventListeners) + : Array.Empty(); + } + + for (var i = 0; i < subs.Count; i++) subs[i].TrySend(ev); + + if (listeners.Count > 0) + { + // Allocate the tagged ClientEvent once, only when a listener exists. + var clientEvent = new ClientEvent(channel, ev); + for (var i = 0; i < listeners.Count; i++) listeners[i].TrySend(clientEvent); + } + } + + // ── 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. + /// + /// Returns (for a reference-type , + /// ) when the server replies with a JSON null or an + /// empty result — the result type is nullable so callers are forced by NRT to + /// handle that case rather than dereferencing a result the contract promised + /// would be non-null. + /// + /// + // See RunWriterAsync: param serialize + result deserialize go through the + // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] + public async Task RequestAsync( + string method, + TParams parameters, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(method); + + 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(); + + // Gate the span-name string-build on HasListeners so the no-listener path + // stays allocation-free; the method goes in the span name (OTel "{op} {target}") + // and as the rpc.method tag. + using var activity = AhpTelemetry.ActivitySource.HasListeners() + ? AhpTelemetry.ActivitySource.StartActivity($"{AhpTelemetryNames.RequestSpan} {method}", ActivityKind.Client) + : null; + activity?.SetTag(AhpTelemetryNames.AttrRpcSystem, AhpTelemetryNames.RpcSystemJsonrpc); + activity?.SetTag(AhpTelemetryNames.AttrRpcMethod, method); + var startTimestamp = Stopwatch.GetTimestamp(); + string outcome = AhpTelemetryNames.OutcomeError; + + var id = Interlocked.Increment(ref _nextId) - 1; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + activity?.SetTag(AhpTelemetryNames.AttrRequestId, id); + AhpTelemetry.InflightRequests.Add(1); + + try + { + // 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 = parameters is null ? null : _serializer.SerializeToElement(parameters); + + 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; + } + AhpTelemetry.MessagesSent.Add(1, new KeyValuePair(AhpTelemetryNames.AttrMessageKind, AhpTelemetryNames.MessageKindRequest)); + + // 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); + activity?.SetStatus(ActivityStatusCode.Ok); + outcome = AhpTelemetryNames.OutcomeOk; + if (resultEl.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return default; + return _serializer.Deserialize(resultEl); + } + catch (OperationCanceledException) + { + _pending.TryRemove(id, out _); + throw; + } + } + catch (Exception ex) + { + // Distinguish caller cancellation (the caller's token) from a request + // timeout (the configured default-timeout fired its linked token) from a + // genuine error — the metric and the span status differ for each. + bool callerCancelled = ex is OperationCanceledException && cancellationToken.IsCancellationRequested; + outcome = ex switch + { + OperationCanceledException when callerCancelled => AhpTelemetryNames.OutcomeCancelled, + OperationCanceledException => AhpTelemetryNames.OutcomeTimeout, + _ => AhpTelemetryNames.OutcomeError, + }; + // A deliberate caller cancellation is not an operation failure. + activity?.SetStatus(callerCancelled ? ActivityStatusCode.Unset : ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + AhpTelemetry.InflightRequests.Add(-1); + AhpTelemetry.RequestDuration.Record( + Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + new KeyValuePair(AhpTelemetryNames.AttrRpcMethod, method), + new KeyValuePair(AhpTelemetryNames.AttrOutcome, outcome)); + } + } + + /// + /// Sends a JSON-RPC notification (fire-and-forget). + /// + // See RunWriterAsync: param serialize goes through the + // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] + public async Task NotifyAsync( + string method, + TParams parameters, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(method); + + if (Volatile.Read(ref _shutdownStarted) == 1) + throw new AhpClientClosedException(); + + JsonElement? paramsEl = parameters is null ? null : _serializer.SerializeToElement(parameters); + + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = method, + Params = paramsEl, + } + }; + + await SendMessageAsync(notif, cancellationToken).ConfigureAwait(false); + AhpTelemetry.MessagesSent.Add(1, new KeyValuePair(AhpTelemetryNames.AttrMessageKind, AhpTelemetryNames.MessageKindNotification)); + } + + 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. Honor the + // caller's token so a cancelled send does not hang indefinitely waiting + // for the writer to pick it up. + await sentTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + // ── Protocol surface ────────────────────────────────────────────────── + + /// Issues the initialize handshake. + public async Task InitializeAsync( + string clientId, + IReadOnlyList? protocolVersions = null, + IReadOnlyList? initialSubscriptions = null, + CancellationToken cancellationToken = default) + { + var versions = protocolVersions is not null + ? new List(protocolVersions) + : new List(ProtocolVersion.Supported); + + var @params = new InitializeParams + { + Channel = ProtocolVersion.RootResourceUri, + ProtocolVersions = versions, + ClientId = clientId, + InitialSubscriptions = initialSubscriptions is { Count: > 0 } + ? new List(initialSubscriptions) + : null, + }; + + // The protocol requires a result for `initialize`; a null/empty result is + // a protocol violation, surfaced loudly rather than returned as null. + return await RequestAsync("initialize", @params, cancellationToken) + .ConfigureAwait(false) + ?? throw new AhpRpcException(JsonRpcErrorCodes.InternalError, "ahp: initialize returned no result"); + } + + /// Re-establishes a dropped connection via the reconnect flow. + public async 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 List(subscriptions) + : new List(), + }; + + try + { + // As with `initialize`, the protocol mandates a `reconnect` result; a null + // result is a protocol violation surfaced loudly. + var result = await RequestAsync("reconnect", @params, cancellationToken) + .ConfigureAwait(false) + ?? throw new AhpRpcException(JsonRpcErrorCodes.InternalError, "ahp: reconnect returned no result"); + AhpTelemetry.Reconnects.Add(1, new KeyValuePair(AhpTelemetryNames.AttrOutcome, AhpTelemetryNames.OutcomeOk)); + return result; + } + catch + { + AhpTelemetry.Reconnects.Add(1, new KeyValuePair(AhpTelemetryNames.AttrOutcome, AhpTelemetryNames.OutcomeError)); + throw; + } + } + + /// + /// 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 + { + // The protocol mandates a `subscribe` result; a null result is a + // protocol violation surfaced loudly rather than returned as null. + var result = await RequestAsync( + "subscribe", new SubscribeParams { Channel = uri }, cancellationToken) + .ConfigureAwait(false) + ?? throw new AhpRpcException(JsonRpcErrorCodes.InternalError, "ahp: subscribe returned no result"); + 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) + { + ArgumentNullException.ThrowIfNull(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); + } + AhpTelemetry.ActiveSubscriptions.Add(1); + // Detach on Close()/Dispose() (whichever ends it) so the registry and the + // active-subscriptions gauge stay accurate regardless of teardown path. + sub.OnClose(() => + { + lock (_subsLock) + { + if (_subscriptions.TryGetValue(uri, out var subsForUri) + && subsForUri.Remove(sub) && subsForUri.Count == 0) + { + _subscriptions.Remove(uri); + } + } + AhpTelemetry.ActiveSubscriptions.Add(-1); + }); + return sub; + } + + /// + /// Sends an unsubscribe notification and drops every local + /// for . + /// + public async Task UnsubscribeAsync(string uri, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(uri); + List subs; + lock (_subsLock) + { + subs = _subscriptions.TryGetValue(uri, out var list) ? new List(list) : new(); + } + // Close() runs each subscription's detach hook, which removes it from the + // registry and decrements the gauge — the single teardown path. + 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) + { + ArgumentNullException.ThrowIfNull(channel); + ArgumentNullException.ThrowIfNull(action); + 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); + } + // Detach on dispose so an abandoned stream is removed from the fan-out + // list rather than receiving (dropped) events for the client's lifetime. + stream.OnClose(() => { lock (_subsLock) { _eventListeners.Remove(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. +/// +/// +// CA1711: "Stream" names the AHP state-change stream concept (mirrors Swift's +// AsyncStream / stateChanges API), not a System.IO.Stream subclass. This is part +// of the established cross-SDK public API surface; renaming would break callers. +[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", + Justification = "StateChangeStream names the AHP connection-state stream (mirrors Swift AsyncStream API), not a System.IO.Stream subclass.")] +public sealed class StateChangeStream : IDisposable +{ + private static readonly KeyValuePair DropTag = new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamState); + private readonly BoundedDropOldestChannel _channel; + private Action? _onClose; + private int _closed; + + /// Creates a new state-change stream. + internal StateChangeStream(int bufferCapacity) + { + _channel = new BoundedDropOldestChannel( + bufferCapacity, _ => AhpTelemetry.DroppedEvents.Add(1, DropTag)); + } + + /// + /// Sets the client's one-shot detach hook, run on the first + /// so the stream is removed from the client's fan-out list no matter how it ends. + /// + internal void OnClose(Action onClose) => _onClose = onClose; + + /// + /// The reader side of the stream. Read from this to receive + /// transitions as they occur. + /// + public ChannelReader States => _channel.Reader; + + /// Stops the stream and detaches it from the client. Safe to call multiple times. + public void Close() + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) != 0) return; + _channel.Close(); + _onClose?.Invoke(); + } + + /// + public void Dispose() => Close(); + + internal void TrySend(ConnectionState state) => _channel.TrySend(state); +} diff --git a/clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs b/clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs new file mode 100644 index 00000000..5eec99d1 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/AssemblyInfo.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +// InternalsVisibleTo lets the test project exercise genuine internal helpers and +// internal state WITHOUT widening the public API surface — the idiomatic .NET pattern +// for unit-testing internals (as dotnet/runtime itself does pervasively). The test +// assembly reaches exactly these, each a real internal-helper or invariant test: +// - ReconnectPolicy.BackoffFor — unit-test the backoff curve / jitter / cap (pure fn) +// - BoundedDropOldestChannel — test drop-oldest eviction counting directly +// - HostEntry — test SessionSummary copy-on-write (torn-read) isolation +// - Subscription.OnClose — test the once-only detach hook directly +// - PendingRequestCount / SubscriptionCount / EventListenerCount / +// StateListenerCount / NextRequestId — observe internal bookkeeping to prove +// lifecycle invariants (a cancelled request goes 1->0; a direct Close() detaches +// from the registry; a disposed stream leaves the fan-out list; a pre-cancelled +// request mints no id). These have no place on the public surface. +// IVT keeps the public contract clean while the internals stay tested. +[assembly: InternalsVisibleTo("AgentHostProtocol.Tests")] diff --git a/clients/dotnet/src/AgentHostProtocol/DependencyInjection/AhpServiceCollectionExtensions.cs b/clients/dotnet/src/AgentHostProtocol/DependencyInjection/AhpServiceCollectionExtensions.cs new file mode 100644 index 00000000..764a104a --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/DependencyInjection/AhpServiceCollectionExtensions.cs @@ -0,0 +1,82 @@ +// IServiceCollection integration. Lives in the Microsoft.Extensions.DependencyInjection +// namespace (the .NET convention for IServiceCollection extensions) so it surfaces with +// a single `using Microsoft.Extensions.DependencyInjection;`. +#nullable enable + +using System; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Registration extensions for the Agent Host Protocol client. +public static class AhpServiceCollectionExtensions +{ + /// + /// Registers the AHP serializer, client-id store, multi-host runtime, and the + /// used to create clients over a caller-supplied + /// transport, and binds . Registrations use TryAdd, + /// so a consumer can override any service (for example a custom + /// ) by registering it before calling this. The + /// singleton is disposed by the container on shutdown + /// (it is ), so no hosted service is required — + /// but the provider MUST be disposed asynchronously (await using / + /// DisposeAsync, as the generic host does); a synchronous + /// ServiceProvider.Dispose() throws because the runtime is async-disposable-only. + /// + /// The service collection. + /// + /// Optional configuration. This applies to the + /// path (single clients); + /// hosts are configured per host via HostConfig.ClientConfig on AddHostAsync. + /// + public static IServiceCollection AddAgentHostProtocol( + this IServiceCollection services, + Action? configureClient = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(SystemTextJsonAhpSerializer.Default); + services.TryAddSingleton(); + // Explicit factory so the IClientIdStore dependency is visible rather than + // left to greedy selection between MultiHostClient's two constructors. + services.TryAddSingleton(sp => new MultiHostClient(sp.GetRequiredService())); + // Forward the interface to the same singleton so consumers can inject the + // mockable IMultiHostClient surface; the concrete registration above owns + // the lifetime (and async disposal), so this MUST resolve the SAME instance + // rather than construct a second runtime. + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + + // Register the options infrastructure unconditionally so IOptions + // always resolves (with defaults when no configuration is supplied); layer the + // caller's configuration on top when provided. + var clientOptions = services.AddOptions(); + if (configureClient is not null) + clientOptions.Configure(configureClient); + + return services; + } + + /// + /// The instrumentation-scope name to pass to OpenTelemetry's + /// AddSource(...) (traces) and AddMeter(...) (metrics) so the AHP + /// client's spans + metrics flow to your exporters. Equal to + /// / . + /// + /// + /// This library intentionally takes no OpenTelemetry dependency — it originates + /// only BCL + + /// instruments, which are + /// near-zero-cost when nothing is listening. Wire it from your composition root: + /// + /// builder.Services.AddOpenTelemetry() + /// .WithTracing(t => t.AddSource(AhpServiceCollectionExtensions.TelemetrySourceName)) + /// .WithMetrics(m => m.AddMeter(AhpServiceCollectionExtensions.TelemetrySourceName)); + /// + /// Naming the source by this constant (rather than re-typing the string) keeps + /// the consumer's OTel pipeline in lock-step with the generated contract. + /// + public const string TelemetrySourceName = AhpTelemetry.Name; +} diff --git a/clients/dotnet/src/AgentHostProtocol/DependencyInjection/IAhpClientFactory.cs b/clients/dotnet/src/AgentHostProtocol/DependencyInjection/IAhpClientFactory.cs new file mode 100644 index 00000000..2a61d477 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/DependencyInjection/IAhpClientFactory.cs @@ -0,0 +1,35 @@ +// DI-resolvable factory for the connect-then-use client: a live ITransport is +// required before an AhpClient exists, so consumers resolve this factory and call +// Connect(transport) rather than injecting an IAhpClient singleton directly. +#nullable enable + +using System; +using Microsoft.Extensions.Options; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Creates instances over a caller-supplied, already-connected +/// , using the serializer and registered +/// via AddAgentHostProtocol. Registered as a singleton by that extension. +/// +public interface IAhpClientFactory +{ + /// + /// Wires the AHP protocol over and returns the client. + /// Synchronous by design: the transport is already connected, so this only wires up + /// state and starts the background reader/writer loops (no I/O). The async work — the + /// transport's own connect and the client's InitializeAsync handshake — is awaited + /// separately by the caller, mirroring the Go and TypeScript clients. + /// + IAhpClient Connect(ITransport transport); +} + +internal sealed class AhpClientFactory(IAhpSerializer serializer, IOptions options) : IAhpClientFactory +{ + public IAhpClient Connect(ITransport transport) + { + ArgumentNullException.ThrowIfNull(transport); + return AhpClient.Connect(transport, options.Value, serializer); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Errors.cs b/clients/dotnet/src/AgentHostProtocol/Errors.cs new file mode 100644 index 00000000..2b570b01 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Errors.cs @@ -0,0 +1,77 @@ +// 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. Mirrors the Go TransportError.Kind field, whose + /// vocabulary is "closed", "io", and "protocol". This client + /// raises "closed" and "io"; it deliberately does not raise + /// "protocol" — where Go surfaces a protocol error on a frame it cannot + /// decode, this client skips the malformed frame and resyncs (counted by the + /// ahp.client.frames.malformed metric). A "protocol" value may still + /// be observed if a server reports one. + /// + 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") { } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs new file mode 100644 index 00000000..b9045973 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs @@ -0,0 +1,182 @@ +// 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, IDisposable +{ + // 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) + { + ArgumentNullException.ThrowIfNull(directory); + Directory = directory; + } + + /// + public async Task LoadAsync(HostId host, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(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) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(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(); + } + } + + /// + /// Releases the mutation semaphore. A store owning a + /// is disposable per the .NET convention; callers creating a store per + /// short-lived operation should dispose it. Safe to call multiple times. + /// + public void Dispose() => _gate.Dispose(); + + 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/HostClientHandle.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs new file mode 100644 index 00000000..733e5090 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs @@ -0,0 +1,83 @@ +// Generation-checked handle onto the underlying single-host AhpClient. +#nullable enable + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// 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); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs new file mode 100644 index 00000000..de1ad240 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs @@ -0,0 +1,39 @@ +// Configuration supplied to MultiHostClient.AddHostAsync. +#nullable enable + +using System.Collections.Generic; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// Everything needs to supervise a single host. +public sealed class HostConfig +{ + /// + /// Stable host identifier. Required — declared with the C# required + /// modifier so the compiler forces every caller to supply it, rather than a + /// silent default that would register an unconfigured host under a sentinel id + /// (and collide with any other host that also forgot to set it). + /// + public required HostId Id { get; init; } + + /// 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; } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs new file mode 100644 index 00000000..21f96544 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostError.cs @@ -0,0 +1,97 @@ +// 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") { } +} + +/// +/// Surfaced on when a host enters its terminal +/// state because reconnection cannot proceed: +/// either the transport dropped while the reconnect policy is disabled, or the +/// policy's maximum attempt count was exhausted. Being +/// -derived, it is pattern-matchable (state.Error is HostReconnectFailedException) +/// rather than an opaque . +/// +public sealed class HostReconnectFailedException : HostException +{ + /// Creates a host-reconnect-failed exception with a specific reason message. + public HostReconnectFailedException(HostId hostId, string message) + : base(hostId, message) { } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostHandle.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostHandle.cs new file mode 100644 index 00000000..5938c13b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostHandle.cs @@ -0,0 +1,78 @@ +// Immutable snapshot of a registered host's observable state. +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// 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. Required (the only producer is + /// , which always sets it) — no misleading + /// sentinel default, consistent with . + /// + public required HostId Id { get; init; } + + /// 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; } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs new file mode 100644 index 00000000..95fbe970 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs @@ -0,0 +1,34 @@ +// Stable identifier for a host registered with MultiHostClient. +#nullable enable + +using System; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// 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); +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostState.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostState.cs new file mode 100644 index 00000000..366b70bf --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostState.cs @@ -0,0 +1,45 @@ +// Host lifecycle state. +#nullable enable + +using System; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// 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", + }; +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs new file mode 100644 index 00000000..64beea8d --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs @@ -0,0 +1,89 @@ +// 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) + { + ArgumentNullException.ThrowIfNull(hostId); + ArgumentNullException.ThrowIfNull(uri); + HostId = hostId; + Uri = 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) + { + ArgumentNullException.ThrowIfNull(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", System.Globalization.CultureInfo.InvariantCulture)); + } + 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); + + /// Returns true if two keys refer to the same host and resource URI. + public static bool operator ==(HostedResourceKey left, HostedResourceKey right) => left.Equals(right); + + /// Returns true if two keys differ in host or resource URI. + public static bool operator !=(HostedResourceKey left, HostedResourceKey right) => !left.Equals(right); + + /// + public override string ToString() => ToStableKey(); +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostedViews.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedViews.cs new file mode 100644 index 00000000..9ae5aa52 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedViews.cs @@ -0,0 +1,52 @@ +// Aggregated view types that tag host-owned items with host id + label. +#nullable enable + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// 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; + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/IMultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/IMultiHostClient.cs new file mode 100644 index 00000000..9572e4df --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/IMultiHostClient.cs @@ -0,0 +1,88 @@ +// The public runtime surface of MultiHostClient, extracted so consumers can +// depend on an interface — mock the multi-host runtime in their own tests, +// substitute it behind their own abstractions — rather than the concrete sealed +// facade. Construction stays on the concrete type (MultiHostClient.SingleAsync / +// the constructors + WithClientIdStore fluent builder), because wiring a live +// registry is a factory concern, not a DI-singleton one. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// The multi-host registry + reconnect-supervisor surface. Implemented by +/// . Depend on this interface to keep consumer call +/// sites mockable and substitutable; construct a concrete instance via +/// or the +/// constructors. +/// +public interface IMultiHostClient : IAsyncDisposable +{ + // ── Registry lifecycle ──────────────────────────────────────────────── + + /// Registers + connects a host, returning its initial handle. + Task AddHostAsync(HostConfig config, CancellationToken cancellationToken = default); + + /// Returns the current handle for , or if unregistered. + HostHandle? Host(HostId id); + + /// Tears down + removes a host. No-op if the host is unknown. + Task RemoveHostAsync(HostId id, CancellationToken cancellationToken = default); + + /// Tears down every host and releases registered event channels. Idempotent. + Task ShutdownAsync(CancellationToken cancellationToken = default); + + // ── Event channels ──────────────────────────────────────────────────── + + /// A fresh channel of state transitions; slow consumers drop oldest. + ChannelReader Events(); + + /// A fresh channel of every from every host. + ChannelReader Subscriptions(); + + /// A fresh per-(host, uri) event channel for ; throws if unregistered. + ChannelReader EventsForHost(HostId host, string uri); + + /// Observable stream of snapshots for ; throws if unregistered. + ChannelReader HostSnapshots(HostId host); + + /// Observable stream of cached session summaries for ; throws if unregistered. + ChannelReader> SessionSummariesForHost(HostId host); + + // ── Aggregated views ────────────────────────────────────────────────── + + /// Aggregated session summaries across every host, newest first, carrying host attribution. + List AggregatedSessions(); + + /// Aggregated agents across every host, in per-host registration order, carrying host attribution. + List AggregatedAgents(); + + // ── Manual reconnect ────────────────────────────────────────────────── + + /// Triggers a manual reconnect on ; throws if unregistered. + Task ReconnectAsync(HostId id, CancellationToken cancellationToken = default); + + /// Triggers a manual reconnect on every host that is not Connected/Connecting; never throws. + Dictionary ReconnectAllUnavailable(); + + // ── Per-host dispatch / subscribe ───────────────────────────────────── + + /// Dispatches on for . + Task DispatchAsync( + HostId host, + StateAction action, + string channel, + long? clientSeq = null, + CancellationToken cancellationToken = default); + + /// Subscribes to on , tracking it for replay across reconnects. + Task SubscribeAsync(HostId host, string uri, CancellationToken cancellationToken = default); + + /// Unsubscribes from on and drops it from the replay set. + Task UnsubscribeAsync(HostId host, string uri, CancellationToken cancellationToken = default); +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs new file mode 100644 index 00000000..1344200e --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs @@ -0,0 +1,1490 @@ +// 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.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +// ─── Internal per-host bookkeeping ─────────────────────────────────────────── + +internal sealed class HostEntry : IDisposable +{ + public HostId Id { get; } + public HostConfig Config { get; } + public string ClientId { get; set; } + + private readonly object _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; } + } + } + + /// + /// Disposes the entry's owned disposables (, the + /// manual-reconnect semaphore, and the current per-attempt CTS if any). Call + /// once at teardown after the supervisor / pump tasks that wait on the + /// semaphore and lifetime token have been awaited — exactly where the + /// teardown paths previously disposed only . + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + LifetimeCts.Dispose(); + _manualReconnect.Dispose(); + // EndAttempt normally disposes the per-attempt CTS, but a teardown that + // interrupts an in-flight attempt may leave one set; dispose it too. + Interlocked.Exchange(ref _attemptCts, null)?.Dispose(); + } + + // Idempotency guard: RemoveHostAsync and ShutdownAsync can both reach a + // given entry's teardown; a second Dispose must be a clean no-op. + private int _disposed; + + 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; + // Copy-on-write: build a NEW SessionSummary instead of mutating the cached + // one in place, so any HostHandle snapshot already handed to a consumer keeps + // its immutable view (the prior object is never touched). Identity fields + // (Resource/Provider/CreatedAt) are preserved per spec; the patch overrides + // the fields it carries; EVERY other field is carried over verbatim — copy + // all 14 so none (e.g. Agent / Annotations, absent from the patch) is dropped. + _sessionSummaries[uri] = new SessionSummary + { + Resource = existing.Resource, + Provider = existing.Provider, + Title = changes.Title ?? existing.Title, + Status = changes.Status ?? existing.Status, + Activity = changes.Activity ?? existing.Activity, + CreatedAt = existing.CreatedAt, + ModifiedAt = changes.ModifiedAt ?? existing.ModifiedAt, + Project = changes.Project ?? existing.Project, + Model = changes.Model ?? existing.Model, + Agent = existing.Agent, + WorkingDirectory = changes.WorkingDirectory ?? existing.WorkingDirectory, + Changes = changes.Changes ?? existing.Changes, + Annotations = existing.Annotations, + Meta = changes.Meta ?? existing.Meta, + }; + } + } + + /// 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 : IMultiHostClient +{ + private readonly ConcurrentDictionary _hosts = new(StringComparer.Ordinal); + private volatile IClientIdStore _store; + + private readonly List> _eventChannels = new(); + private readonly object _eventsLock = new(); + + private readonly List> _subChannels = new(); + private readonly object _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 object _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(); + + // ── Cached drop-tags (one allocation per stream kind, not per drop callback) ── + // The bounded-channel itemDropped callback fires per evicted event under + // back-pressure; building the KeyValuePair inline would allocate on every + // drop. Hoisting each stream's tag to a static mirrors the single-host + // drop-path (AhpClient.DropTag / Subscription.DropTag) and routes the + // ahp.stream attribute value through the generated StreamHost* constants + // rather than a raw literal. + private static readonly KeyValuePair DropTagHostEvent = + new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamHostEvent); + private static readonly KeyValuePair DropTagHostSubscription = + new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamHostSubscription); + private static readonly KeyValuePair DropTagHostResource = + new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamHostResource); + private static readonly KeyValuePair DropTagHostSnapshot = + new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamHostSnapshot); + private static readonly KeyValuePair DropTagHostSummaries = + new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamHostSummaries); + + // ── Reconnect-outcome tags (cached, one allocation per outcome) ────────── + private static readonly KeyValuePair ReconnectOk = + new(AhpTelemetryNames.AttrOutcome, AhpTelemetryNames.OutcomeOk); + private static readonly KeyValuePair ReconnectError = + new(AhpTelemetryNames.AttrOutcome, AhpTelemetryNames.OutcomeError); + + // 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) + { + ArgumentNullException.ThrowIfNull(config); + if (config.Id is null) throw new ArgumentException("HostConfig.Id is required.", nameof(config)); + if (config.TransportFactory is null) + throw new ArgumentException($"HostConfig.TransportFactory is required for {config.Id}.", nameof(config)); + + // 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. CancellationToken.None is intentional: the supervisor + // manages its own lifetime via entry.LifetimeCts.Token; the AddHostAsync + // cancellationToken is only for the initial connection setup and must not + // propagate to the long-lived background task (CA2016 false-positive here). + entry.SupervisorTask = Task.Run(() => SuperviseAsync(entry), CancellationToken.None); + + 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.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 ChannelReader Events() + { + var ch = Channel.CreateBounded( + new BoundedChannelOptions(64) { FullMode = BoundedChannelFullMode.DropOldest }, + _ => AhpTelemetry.DroppedEvents.Add(1, DropTagHostEvent)); + lock (_eventsLock) { _eventChannels.Add(ch); } + return ch.Reader; + } + + /// + /// Returns a channel that receives every from + /// every registered host. + /// + public ChannelReader Subscriptions() + { + var ch = Channel.CreateBounded( + new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropOldest }, + _ => AhpTelemetry.DroppedEvents.Add(1, DropTagHostSubscription)); + 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.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 = _subChannels.Count == 0 + ? null + : new List>(_subChannels); + } + if (channels is not null) + 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 = _subChannels.Count == 0 + ? null + : new List>(_subChannels); + } + if (channels is not null) + 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 HostReconnectFailedException(entry.Id, "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(); + // Reconnect succeeded: count one ok outcome. + AhpTelemetry.Reconnects.Add(1, ReconnectOk); + 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(); + // Reconnect attempt failed: count one error outcome (per attempt). + AhpTelemetry.Reconnects.Add(1, ReconnectError); + /* retry after backoff */ + } + + attempt++; + if (policy.MaxAttempts > 0 && attempt > policy.MaxAttempts) + { + // Policy exhausted: count one terminal error outcome on top of + // the per-attempt errors above, so a consumer can alert on + // "gave up reconnecting" distinctly from "an attempt failed". + AhpTelemetry.Reconnects.Add(1, ReconnectError); + SetHostState(entry, new HostState + { + Kind = HostStateKind.Failed, + Error = new HostReconnectFailedException(entry.Id, $"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) + { + if (_eventChannels.Count == 0) return; + 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 ChannelReader EventsForHost(HostId host, string uri) + { + lock (_perHostLock) + { + if (!_hosts.ContainsKey(host.ToString())) throw new UnknownHostException(host); + // Bounded drop-oldest (like the other per-host streams) so a stalled or + // abandoned reader can't grow the buffer without bound; a slow consumer + // loses the stalest events rather than leaking memory. + var ch = Channel.CreateBounded( + new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropOldest }, + _ => AhpTelemetry.DroppedEvents.Add(1, DropTagHostResource)); + 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 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 = Channel.CreateBounded( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }, + _ => AhpTelemetry.DroppedEvents.Add(1, DropTagHostSnapshot)); + 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 ChannelReader> SessionSummariesForHost(HostId host) + { + lock (_perHostLock) + { + if (!_hosts.TryGetValue(host.ToString(), out var entry)) throw new UnknownHostException(host); + var ch = Channel.CreateBounded>( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }, + _ => AhpTelemetry.DroppedEvents.Add(1, DropTagHostSummaries)); + 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 Dictionary ReconnectAllUnavailable() + { + 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 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) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(action); + ArgumentNullException.ThrowIfNull(channel); + 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) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(uri); + 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. The + // protocol mandates a result; a null result is a protocol violation + // surfaced loudly rather than returned as null. + var result = await client.RequestAsync( + "subscribe", + new SubscribeParams { Channel = uri }, + cancellationToken).ConfigureAwait(false) + ?? throw new AhpRpcException(JsonRpcErrorCodes.InternalError, "ahp: subscribe returned no result"); + 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) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(uri); + 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 Channel Channel { get; } + + public PerResourceListener(string uri, Channel channel) + { + Uri = uri; Channel = channel; + } + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs new file mode 100644 index 00000000..9dc60327 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs @@ -0,0 +1,110 @@ +// Thread-safe (hostId, URI) → state-snapshot mirror. +// Port of multi_host_state_mirror.go. +#nullable enable + +using System.Collections.Concurrent; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// +/// 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 _chats = new(); + private readonly ConcurrentDictionary _terminals = new(); + private readonly ConcurrentDictionary _changesets = new(); + + /// Stores for . + public void PutRoot(string hostId, RootState root) + { + ArgumentNullException.ThrowIfNull(hostId); + ArgumentNullException.ThrowIfNull(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) + { + ArgumentNullException.ThrowIfNull(hostId); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(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 chat snapshot under (hostId, uri). + public void PutChat(string hostId, string uri, ChatState state) + { + ArgumentNullException.ThrowIfNull(hostId); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(state); + _chats[new HostedResourceKey(hostId, uri)] = state; + } + + /// Returns the chat snapshot at (hostId, uri), or (default, false) if absent. + public (ChatState? Value, bool Found) Chat(string hostId, string uri) => + _chats.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) + { + ArgumentNullException.ThrowIfNull(hostId); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(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) + { + ArgumentNullException.ThrowIfNull(hostId); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(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 _chats.Keys) if (k.HostId.ToString() == hostId) _chats.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 _); + _chats.TryRemove(key, out _); + _terminals.TryRemove(key, out _); + _changesets.TryRemove(key, out _); + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostSupport.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostSupport.cs new file mode 100644 index 00000000..43995f22 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostSupport.cs @@ -0,0 +1,100 @@ +// Supporting types: IClientIdStore, host events, and subscription events. +#nullable enable + +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// 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; + } +} + +/// +/// 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; + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/ReconnectPolicy.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/ReconnectPolicy.cs new file mode 100644 index 00000000..aa2b1371 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/ReconnectPolicy.cs @@ -0,0 +1,87 @@ +// Reconnect policy + transport factory delegate. +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Hosts; + +/// Factory delegate that opens a fresh transport for a given host. +public delegate Task HostTransportFactory(HostId hostId, CancellationToken cancellationToken); + +/// 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; + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs b/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs new file mode 100644 index 00000000..be1307b8 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs @@ -0,0 +1,81 @@ +// The public protocol surface of AhpClient, extracted so consumers can depend on +// an interface — mock it in tests, substitute it behind their own abstractions — +// rather than the concrete sealed client. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol; + +/// +/// The Agent Host Protocol client surface. Implemented by ; +/// a live is required, so construction stays a factory +/// (), not a +/// parameterless DI singleton. Depend on this interface to keep call sites +/// mockable and substitutable. +/// +public interface IAhpClient : IAsyncDisposable +{ + /// The current connection state, readable synchronously. + ConnectionState ConnectionState { get; } + + /// Completes once the client begins teardown (via shutdown or a transport failure). + Task Completion { get; } + + /// The error that caused teardown, or after a clean shutdown. + [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", + Justification = "Error matches the established AhpClient.Error public property; renaming would diverge the interface from its implementation.")] + Exception? Error { get; } + + /// Gracefully tears down the client. Safe to call multiple times. + Task ShutdownAsync(CancellationToken cancellationToken = default); + + /// Registers a handler for server-initiated JSON-RPC requests (replaces any prior handler). + void SetServerRequestHandler(ServerRequestHandler? handler); + + /// Returns a fresh multicast stream of future connection-state transitions. + StateChangeStream CreateStateChangeStream(); + + /// Returns a fresh top-level event stream over every inbound event. + EventStream CreateEventStream(); + + /// Issues a JSON-RPC request and awaits the typed result. + Task RequestAsync(string method, TParams parameters, CancellationToken cancellationToken = default); + + /// Sends a JSON-RPC notification (fire-and-forget). + Task NotifyAsync(string method, TParams parameters, CancellationToken cancellationToken = default); + + /// Issues the initialize handshake. + Task InitializeAsync( + string clientId, + IReadOnlyList? protocolVersions = null, + IReadOnlyList? initialSubscriptions = null, + CancellationToken cancellationToken = default); + + /// Re-establishes a dropped connection via the reconnect flow. + Task ReconnectAsync( + string clientId, + long lastSeenServerSeq, + IReadOnlyList? subscriptions = null, + CancellationToken cancellationToken = default); + + /// Sends a subscribe request and returns the snapshot plus a per-URI handle. + Task<(SubscribeResult Result, Subscription Sub)> SubscribeAsync(string uri, CancellationToken cancellationToken = default); + + /// Returns a local subscription for without sending a request. + Subscription AttachSubscription(string uri); + + /// Sends an unsubscribe notification and drops local subscriptions for . + Task UnsubscribeAsync(string uri, CancellationToken cancellationToken = default); + + /// Fires a write-ahead dispatchAction notification. + Task DispatchAsync( + string channel, + StateAction action, + long? clientSeq = null, + CancellationToken cancellationToken = default); +} diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs new file mode 100644 index 00000000..46211f45 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -0,0 +1,1784 @@ +// 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.Concurrent; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +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 volatile 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) + { + s_now = provider ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + private static long NowMs() => s_now(); + + private static string NowIso() => + DateTimeOffset.FromUnixTimeMilliseconds(NowMs()).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture); + + // 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(ChatState 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 ChatSummaryStatus(ChatState 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.Status & ~StatusActivityMask) | activity; + } + + private static void RefreshChatStatus(ChatState state) => + state.Status = ChatSummaryStatus(state, null); + + private static void TouchModifiedChat(ChatState state) => + state.ModifiedAt = NowIso(); + + private static void TouchModified(SessionState state) => + state.Summary.ModifiedAt = NowMs(); + + // ─── Active-turn helpers ─────────────────────────────────────────────── + + private static ReduceOutcome EndTurn( + ChatState 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; + TouchModifiedChat(state); + state.Status = ChatSummaryStatus(state, terminalStatus); + return ReduceOutcome.Applied; + } + + private static void UpsertChatInputRequest(ChatState state, ChatInputRequest 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.Status = ChatSummaryStatus(state, null); + TouchModifiedChat(state); + state.Status = WithStatusFlag(state.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( + ChatState 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( + ChatState 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) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(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) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(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 SessionTitleChangedAction a: + state.Summary.Title = a.Title; + TouchModified(state); + return ReduceOutcome.Applied; + 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 SessionActiveClientSetAction a: + { + // Upsert keyed by clientId: replace the existing entry with the + // same clientId, otherwise append. Mirrors the TS reducer. + int idx = state.ActiveClients.FindIndex(c => c.ClientId == a.ActiveClient.ClientId); + if (idx < 0) + { + state.ActiveClients.Add(a.ActiveClient); + } + else + { + state.ActiveClients[idx] = a.ActiveClient; + } + + return ReduceOutcome.Applied; + } + case SessionActiveClientRemovedAction a: + { + // Remove the entry matching clientId; no-op when none matches. + int idx = state.ActiveClients.FindIndex(c => c.ClientId == a.ClientId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + state.ActiveClients.RemoveAt(idx); + 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 SessionChatAddedAction a: + return ApplySessionChatAdded(state, a); + case SessionChatRemovedAction a: + return ApplySessionChatRemoved(state, a); + case SessionChatUpdatedAction a: + return ApplySessionChatUpdated(state, a); + case SessionDefaultChatChangedAction a: + state.DefaultChat = a.DefaultChat; + return ReduceOutcome.Applied; + } + + return ReduceOutcome.OutOfScope; + } + + // ─── Chat-channel reducer ────────────────────────────────────────────── + + /// + /// Applies to the in + /// place. Returns for actions that + /// target a different state tree. + /// + public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(action); + switch (action.Value) + { + case ChatTurnStartedAction a: + return ApplyChatTurnStarted(state, a); + case ChatDeltaAction a: + return UpdateResponsePart(state, a.TurnId, a.PartId, p => + { + if (p.Value is MarkdownResponsePart m) + { + m.Content += a.Content; + } + }); + case ChatResponsePartAction a: + if (state.ActiveTurn is null || state.ActiveTurn.Id != a.TurnId) + { + return ReduceOutcome.NoOp; + } + + state.ActiveTurn.ResponseParts.Add(a.Part); + return ReduceOutcome.Applied; + case ChatTurnCompleteAction a: + return EndTurn(state, a.TurnId, TurnState.Complete, null, null); + case ChatTurnCancelledAction a: + return EndTurn(state, a.TurnId, TurnState.Cancelled, null, null); + case ChatErrorAction a: + return EndTurn(state, a.TurnId, TurnState.Error, SessionStatus.Error, a.Error); + case ChatToolCallStartAction 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 ChatToolCallDeltaAction a: + return ApplyChatToolCallDelta(state, a); + case ChatToolCallReadyAction a: + return WithChatRefresh(state, ApplyChatToolCallReady(state, a)); + case ChatToolCallConfirmedAction a: + return WithChatRefresh(state, ApplyChatToolCallConfirmed(state, a)); + case ChatToolCallCompleteAction a: + return WithChatRefresh(state, ApplyChatToolCallComplete(state, a)); + case ChatToolCallResultConfirmedAction a: + return WithChatRefresh(state, ApplyChatToolCallResultConfirmed(state, a)); + case ChatToolCallContentChangedAction a: + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + if (tc.Value is ToolCallRunningState r) + { + if (a.Meta is not null) + { + r.Meta = a.Meta; + } + + r.Content = CopyList(a.Content)!; + } + + return tc; + }); + case ChatUsageAction a: + if (state.ActiveTurn is null || state.ActiveTurn.Id != a.TurnId) + { + return ReduceOutcome.NoOp; + } + + state.ActiveTurn.Usage = a.Usage; + return ReduceOutcome.Applied; + case ChatReasoningAction a: + return UpdateResponsePart(state, a.TurnId, a.PartId, p => + { + if (p.Value is ReasoningResponsePart r) + { + r.Content += a.Content; + } + }); + case ChatTruncatedAction a: + return ApplyChatTruncated(state, a.TurnId); + case ChatInputRequestedAction a: + UpsertChatInputRequest(state, a.Request); + return ReduceOutcome.Applied; + case ChatInputAnswerChangedAction a: + return ApplyChatInputAnswerChanged(state, a); + case ChatInputCompletedAction a: + return ApplyChatInputCompleted(state, a); + case ChatPendingMessageSetAction a: + return ApplyChatPendingMessageSet(state, a); + case ChatPendingMessageRemovedAction a: + return ApplyChatPendingMessageRemoved(state, a); + case ChatQueuedMessagesReorderedAction a: + return ApplyChatQueuedMessagesReordered(state, a); + } + + return ReduceOutcome.OutOfScope; + } + + private static ReduceOutcome WithChatRefresh(ChatState state, ReduceOutcome outcome) + { + if (outcome == ReduceOutcome.Applied) + { + RefreshChatStatus(state); + } + + return outcome; + } + + private static ReduceOutcome ApplyChatTurnStarted(ChatState state, ChatTurnStartedAction a) + { + state.ActiveTurn = new ActiveTurn + { + Id = a.TurnId, + Message = a.Message, + ResponseParts = new List(), + }; + state.Status = ChatSummaryStatus(state, null); + TouchModifiedChat(state); + state.Status = WithStatusFlag(state.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 ApplyChatToolCallDelta(ChatState state, ChatToolCallDeltaAction 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.Meta is not null) + { + s.Meta = a.Meta; + } + + if (a.InvocationMessage is not null) + { + s.InvocationMessage = a.InvocationMessage; + } + + return tc; + }); + } + + private static ReduceOutcome ApplyChatToolCallReady(ChatState state, ChatToolCallReadyAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + ToolCallCommon common = ToolCallMeta(tc); + if (a.Meta is not null) + { + common = common with { Meta = a.Meta }; + } + + 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 ApplyChatToolCallConfirmed(ChatState state, ChatToolCallConfirmedAction 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.Meta is not null) + { + s = s with { Meta = a.Meta }; + } + + 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 ApplyChatToolCallComplete(ChatState state, ChatToolCallCompleteAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + ToolCallCommon common = ToolCallMeta(tc); + if (a.Meta is not null) + { + common = common with { Meta = a.Meta }; + } + + 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 ApplyChatToolCallResultConfirmed(ChatState state, ChatToolCallResultConfirmedAction a) + { + return UpdateToolCall(state, a.TurnId, a.ToolCallId, tc => + { + if (tc.Value is not ToolCallPendingResultConfirmationState s) + { + return tc; + } + + if (a.Meta is not null) + { + s = s with { Meta = a.Meta }; + } + + 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 ApplyChatTruncated(ChatState 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; + TouchModifiedChat(state); + state.Status = ChatSummaryStatus(state, null); + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyChatInputAnswerChanged(ChatState state, ChatInputAnswerChangedAction a) + { + List? list = state.InputRequests; + int idx = list?.FindIndex(r => r.Id == a.RequestId) ?? -1; + if (idx < 0 || list is null) + { + return ReduceOutcome.NoOp; + } + + ChatInputRequest 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; + } + + TouchModifiedChat(state); + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyChatInputCompleted(ChatState state, ChatInputCompletedAction a) + { + List? list = state.InputRequests; + if (list is null) + { + return ReduceOutcome.NoOp; + } + + int idx = list.FindIndex(r => r.Id == a.RequestId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + list.RemoveAt(idx); + state.InputRequests = list.Count == 0 ? null : list; + RefreshChatStatus(state); + TouchModifiedChat(state); + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplyChatPendingMessageSet(ChatState state, ChatPendingMessageSetAction 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 ApplyChatPendingMessageRemoved(ChatState state, ChatPendingMessageRemovedAction 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 ApplyChatQueuedMessagesReordered(ChatState state, ChatQueuedMessagesReorderedAction 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; + } + + // ─── Session chat catalog helpers ────────────────────────────────────── + + private static ReduceOutcome ApplySessionChatAdded(SessionState state, SessionChatAddedAction a) + { + List chats = state.Chats; + int idx = chats.FindIndex(c => c.Resource == a.Summary.Resource); + if (idx >= 0) + { + chats[idx] = a.Summary; + } + else + { + chats.Add(a.Summary); + } + + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplySessionChatRemoved(SessionState state, SessionChatRemovedAction a) + { + int idx = state.Chats.FindIndex(c => c.Resource == a.Chat); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + state.Chats.RemoveAt(idx); + if (state.DefaultChat == a.Chat) + { + state.DefaultChat = null; + } + + return ReduceOutcome.Applied; + } + + private static ReduceOutcome ApplySessionChatUpdated(SessionState state, SessionChatUpdatedAction a) + { + int idx = state.Chats.FindIndex(c => c.Resource == a.Chat); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + ChatSummary s = state.Chats[idx]; + PartialChatSummary ch = a.Changes; + if (ch.Title is not null) { s.Title = ch.Title; } + if (ch.Status is not null) { s.Status = ch.Status.Value; } + if (ch.Activity is not null) { s.Activity = ch.Activity; } + if (ch.ModifiedAt is not null) { s.ModifiedAt = ch.ModifiedAt; } + if (ch.Model is not null) { s.Model = ch.Model; } + if (ch.Agent is not null) { s.Agent = ch.Agent; } + if (ch.Origin is not null) { s.Origin = ch.Origin; } + if (ch.Interactivity is not null) { s.Interactivity = ch.Interactivity; } + if (ch.WorkingDirectory is not null) { s.WorkingDirectory = ch.WorkingDirectory; } + 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) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(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, + // `output` is schema-required; it starts empty and accrues + // via terminal/data appends (see AppendTerminalData). + Output = "", + 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) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(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 ChangesetContentChangedAction a: + // Full content replacement (snapshots / bulk refreshes): `files` + // always replaces the previous file list. `operations` replaces + // the previous list only when present; when omitted (wire + // `operations` absent) the operation list is left unchanged. + // `error` is set when present and cleared otherwise, mirroring + // the canonical TypeScript reducer. + state.Files = CopyList(a.Files)!; + if (a.Operations is not null) + { + state.Operations = CopyList(a.Operations); + } + + state.Error = a.Error; + 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) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(action); + 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) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(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; + } + + case AnnotationsUpdatedAction a: + { + int idx = state.Annotations.FindIndex(t => t.Id == a.AnnotationId); + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + + Annotation ann = state.Annotations[idx]; + state.Annotations[idx] = ann with + { + TurnId = a.TurnId ?? ann.TurnId, + Resource = a.Resource ?? ann.Resource, + Range = a.Range ?? ann.Range, + Resolved = a.Resolved ?? ann.Resolved, + }; + 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 + { + // Chat-channel actions (post-#213) + "chat/turnStarted", + "chat/toolCallConfirmed", + "chat/toolCallComplete", + "chat/toolCallResultConfirmed", + "chat/turnCancelled", + "chat/pendingMessageSet", + "chat/pendingMessageRemoved", + "chat/queuedMessagesReordered", + "chat/inputAnswerChanged", + "chat/inputCompleted", + // Session-level actions that remain on the session channel + "session/modelChanged", + "session/activeClientSet", + "session/activeClientRemoved", + "session/customizationToggled", + "session/isReadChanged", + "session/isArchivedChanged", + }.ToFrozenSet(StringComparer.Ordinal); + + /// + /// Checks whether may be dispatched by a client. + /// The action's wire type is read directly from the variant's + /// Type discriminator (mapped to its wire string via the generated + /// [WireValue] attributes) and tested for membership in + /// — without serializing the whole + /// action graph. An unknown variant carried as a raw + /// reads its type field directly. Mirrors the Swift client's + /// isClientDispatchable. + /// + // Reads the variant's `Type` property reflectively + the [WireValue] attributes + // on ActionType — trim/AOT-relevant, so the declaration is kept; the cost is one + // cached property read, not a full serialize of the action's nested payload. + [RequiresUnreferencedCode("Reflects over the action variant's Type property and ActionType's [WireValue] members; trimming may remove the metadata it reads. Declared (not suppressed) so trim/AOT consumers are warned at the call site.")] + [RequiresDynamicCode("Reflects over the action variant's Type property and ActionType's [WireValue] members.")] + public static bool IsClientDispatchable(StateAction action) + { + ArgumentNullException.ThrowIfNull(action); + + var inner = action.Value; + switch (inner) + { + case null: + return false; + // Unknown variant preserved as raw JSON: read its `type` field directly. + case JsonElement el: + return el.ValueKind == JsonValueKind.Object + && el.TryGetProperty("type", out var t) + && t.ValueKind == JsonValueKind.String + && t.GetString() is { } raw + && ClientDispatchableActions.Contains(raw); + default: + // Known variant record: read its ActionType discriminator and map it + // to the wire string the serializer would have emitted. + if (TryReadActionType(inner, out var actionType) + && s_actionTypeWire.TryGetValue(actionType, out var wire)) + { + return ClientDispatchableActions.Contains(wire); + } + return false; + } + } + + // Cache: variant CLR type -> its `Type` (ActionType) property accessor. Every + // generated state-action variant carries `public ActionType Type { get; init; }`. + private static readonly ConcurrentDictionary s_typeProperty = new(); + + // ActionType -> wire string, derived once from the [WireValue] attributes (the + // same source the WireEnumConverter uses), so the lookup needs no serialize. + private static readonly Dictionary s_actionTypeWire = BuildActionTypeWireMap(); + + [UnconditionalSuppressMessage("Trimming", "IL2070", + Justification = "GetProperty(\"Type\") over a state-action variant CLR type; the variants are all preserved generated records with a public ActionType Type property.")] + private static bool TryReadActionType(object variant, out ActionType actionType) + { + var prop = s_typeProperty.GetOrAdd(variant.GetType(), static t => t.GetProperty("Type")); + if (prop is not null && prop.GetValue(variant) is ActionType at) + { + actionType = at; + return true; + } + actionType = default; + return false; + } + + private static Dictionary BuildActionTypeWireMap() + { + var map = new Dictionary(); + foreach (FieldInfo field in typeof(ActionType).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var value = (ActionType)field.GetValue(null)!; + map[value] = field.GetCustomAttribute()?.Value ?? field.Name; + } + return map; + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/Subscription.cs b/clients/dotnet/src/AgentHostProtocol/Subscription.cs new file mode 100644 index 00000000..f2e525e6 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Subscription.cs @@ -0,0 +1,268 @@ +// 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; + +namespace Microsoft.AgentHostProtocol; + +// ─── Subscription events ───────────────────────────────────────────────────── + +/// Marker base class for all subscription event variants. +public abstract class SubscriptionEvent +{ + private protected 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; + } +} + +// ─── Shared channel wrapper ────────────────────────────────────────────────── + +/// +/// Internal bounded drop-oldest channel shared by the three public stream +/// wrappers (, , and +/// StateChangeStream). Encapsulates the creation, +/// the idempotent close lifecycle, and the drop-oldest delivery so each public +/// wrapper stays a thin, sealed, domain-named handle. +/// +internal sealed class BoundedDropOldestChannel +{ + private readonly Channel _channel; + private int _closed; + + internal BoundedDropOldestChannel(int bufferCapacity, Action? onDropped = null) + { + var options = new BoundedChannelOptions(bufferCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = false, + SingleWriter = false, + }; + // BoundedChannelOptions' itemDropped callback (net7+) reports each + // back-pressure eviction EXACTLY — no racy Count-then-write probe — and is + // invoked inline on the writer with the evicted item. + _channel = onDropped is null + ? Channel.CreateBounded(options) + : Channel.CreateBounded(options, onDropped); + } + + internal ChannelReader Reader => _channel.Reader; + + /// Completes the channel. Safe to call multiple times. + internal void Close() + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) == 0) + { + _channel.Writer.TryComplete(); + } + } + + /// + /// Delivers the item, evicting the oldest buffered item if the channel is full + /// () — the newest item is always + /// accepted, so a slow consumer loses the stalest items rather than the latest. + /// Each eviction is reported via the onDropped callback supplied at + /// construction. Mirrors the Go trySend. + /// + internal void TrySend(T item) + { + if (Volatile.Read(ref _closed) == 1) return; + _channel.Writer.TryWrite(item); + } +} + +// ─── 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 static readonly KeyValuePair DropTag = new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamSubscription); + private readonly BoundedDropOldestChannel _channel; + private Action? _onClose; + 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 = new BoundedDropOldestChannel( + bufferCapacity, _ => AhpTelemetry.DroppedEvents.Add(1, DropTag)); + } + + /// + /// Sets the client's one-shot detach hook, run on the first + /// so the subscription is removed from the client's registry (and its metric + /// decremented) no matter which API ends it. + /// + internal void OnClose(Action onClose) => _onClose = onClose; + + /// + /// 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) return; + _channel.Close(); + _onClose?.Invoke(); + } + + /// + public void Dispose() => Close(); + + internal void TrySend(SubscriptionEvent ev) => _channel.TrySend(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 . +/// +// CA1711: "Stream" here names the AHP event-stream concept (mirroring Go's +// EventStream and Swift's AsyncStream usage), not a System.IO.Stream subclass. +// The name is part of the established cross-SDK API surface. +[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", + Justification = "EventStream names the AHP event-stream abstraction (mirrors Go/Swift API), not a System.IO.Stream subclass.")] +public sealed class EventStream : IDisposable +{ + private readonly BoundedDropOldestChannel _channel; + private Action? _onClose; + private int _closed; + + private static readonly KeyValuePair DropTag = new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamEvent); + + /// Creates a new event stream. + internal EventStream(int bufferCapacity) + { + _channel = new BoundedDropOldestChannel( + bufferCapacity, _ => AhpTelemetry.DroppedEvents.Add(1, DropTag)); + } + + /// + /// Sets the client's one-shot detach hook, run on the first + /// so the stream is removed from the client's fan-out list no matter how it ends. + /// + internal void OnClose(Action onClose) => _onClose = onClose; + + /// + /// The reader side of the event stream. Read from this to receive + /// s as they arrive. + /// + public ChannelReader Events => _channel.Reader; + + /// Stops the stream and detaches it from the client. Safe to call multiple times. + public void Close() + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) != 0) return; + _channel.Close(); + _onClose?.Invoke(); + } + + /// + public void Dispose() => Close(); + + internal void TrySend(ClientEvent ev) => _channel.TrySend(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..92186fc6 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs @@ -0,0 +1,124 @@ +#nullable enable + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.AgentHostProtocol; + +/// +/// Shared for the Agent Host Protocol. +/// The camelCase naming policy maps PascalCase C# properties to their +/// camelCase wire names by default; the generated types carry an explicit +/// [JsonPropertyName] only where the wire name diverges from that +/// (the jsonrpc envelope field and the snake_case _meta / +/// OAuth resource-metadata fields). +/// +public static class AhpJson +{ + /// The canonical serializer options used by the default serializer. + public static readonly JsonSerializerOptions Options = new() + { + // Most wire names are camelCase(PropertyName); generated types carry an + // explicit [JsonPropertyName] only where they aren't. + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // 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, + }; + + static AhpJson() + { + // Freeze the shared options so consumer mutation fails fast rather than + // poisoning the global wire config. IL2026/IL3050: populateMissingResolver + // wires the reflection-based default resolver (this library targets + // reflection-based STJ until a JsonSerializerContext lands, per + // docs/decisions/serialization.md). +#pragma warning disable IL2026, IL3050 + Options.MakeReadOnly(populateMissingResolver: true); +#pragma warning restore IL2026, IL3050 + } +} + +/// +/// 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(); + + // This serializer is the reflection-based System.Text.Json path (source-gen + // deferred per docs/decisions/serialization.md), so every (de)serialize entry + // point is genuinely trim/AOT-unsafe: STJ may need types that cannot be + // statically analyzed (under trimming) or runtime code generation (under + // Native AOT). The reflection unsafety is declared on the contract via these + // attributes, matching the same attributes on the IAhpSerializer interface — + // the honest interim state until a JsonSerializerContext lands. (The messages + // mirror IAhpSerializer's SerializerTrimWarnings; the trim analyzer only + // requires the attribute to be PRESENT on both, not message-identical, and + // that constant is internal to the Abstractions assembly.) + private const string TrimUnreferencedCode = + "JSON (de)serialization here is reflection-based and may reference types that cannot be statically analyzed when trimming. Provide a JsonSerializerContext or preserve the wire types."; + private const string TrimDynamicCode = + "JSON (de)serialization here is reflection-based and may require runtime code generation under Native AOT. Use System.Text.Json source generation for AOT."; + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public string Serialize(T value) => JsonSerializer.Serialize(value, _options); + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public JsonElement SerializeToElement(T value) => + JsonSerializer.SerializeToElement(value, _options); + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public T Deserialize(string json) => + JsonSerializer.Deserialize(json, _options) + ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public T Deserialize(ReadOnlySpan utf8Json) => + JsonSerializer.Deserialize(utf8Json, _options) + ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public T Deserialize(JsonElement element) => + element.Deserialize(_options) + ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public JsonRpcMessage DecodeMessage(TransportMessage message) => + message.Frame == TransportFrame.Text + ? Deserialize(message.Text ?? string.Empty) + : Deserialize(message.Binary.Span); + + /// + [RequiresUnreferencedCode(TrimUnreferencedCode)] + [RequiresDynamicCode(TrimDynamicCode)] + public TransportMessage EncodeMessage(JsonRpcMessage message) => + TransportMessage.FromText(Serialize(message)); +} diff --git a/clients/dotnet/src/AgentHostProtocol/Telemetry.cs b/clients/dotnet/src/AgentHostProtocol/Telemetry.cs new file mode 100644 index 00000000..2ff70674 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Telemetry.cs @@ -0,0 +1,77 @@ +// OpenTelemetry-native instrumentation for the AHP client — a single +// ActivitySource (traces) + a single Meter (metrics), both from the BCL's +// System.Diagnostics. They are consumed directly by OpenTelemetry +// (.AddSource(AhpTelemetry.Name) / .AddMeter(AhpTelemetry.Name)) and are +// ~zero-cost when nothing is listening (StartActivity() returns null; metric +// recording is a no-op with no collector). No Microsoft.Extensions.Logging +// dependency: the library originates traces + metrics; a consumer's own ILogger +// written inside one of these spans already auto-correlates to it. +#nullable enable + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; + +namespace Microsoft.AgentHostProtocol; + +/// +/// The AHP client's observability surface. Light it up from OpenTelemetry with +/// .AddSource(AhpTelemetry.Name) (traces) and .AddMeter(AhpTelemetry.Name) +/// (metrics); both are near-zero-cost when no listener is attached. +/// +public static class AhpTelemetry +{ + /// + /// The instrumentation name shared by the + /// and the . Pass it to + /// OpenTelemetry's AddSource / AddMeter. + /// + public const string Name = AhpTelemetryNames.Source; + + private static readonly string? Version = + typeof(AhpTelemetry).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(AhpTelemetry).Assembly.GetName().Version?.ToString(); + + internal static readonly ActivitySource ActivitySource = new(Name, Version); + internal static readonly Meter Meter = new(Name, Version); + + // ── Metrics ──────────────────────────────────────────────────────────── + // Names follow OTel-style dotted lowercase. Tags are added at the call site. + + // The `description:` strings reference the generated AhpTelemetryNames.*Description + // constants — the SINGLE source for each instrument's human-readable description + // (also rendered as the doc-comment summary above each metric name in the + // generated holder). Referencing the constant rather than re-typing the literal + // keeps the runtime metadata in lock-step with the generated contract. + internal static readonly Counter MessagesSent = + Meter.CreateCounter(AhpTelemetryNames.MessagesSent, unit: AhpTelemetryNames.MessagesSentUnit, + description: AhpTelemetryNames.MessagesSentDescription); + + internal static readonly Counter MessagesReceived = + Meter.CreateCounter(AhpTelemetryNames.MessagesReceived, unit: AhpTelemetryNames.MessagesReceivedUnit, + description: AhpTelemetryNames.MessagesReceivedDescription); + + internal static readonly Histogram RequestDuration = + Meter.CreateHistogram(AhpTelemetryNames.RequestDuration, unit: AhpTelemetryNames.RequestDurationUnit, + description: AhpTelemetryNames.RequestDurationDescription); + + internal static readonly UpDownCounter InflightRequests = + Meter.CreateUpDownCounter(AhpTelemetryNames.RequestsInFlight, unit: AhpTelemetryNames.RequestsInFlightUnit, + description: AhpTelemetryNames.RequestsInFlightDescription); + + internal static readonly UpDownCounter ActiveSubscriptions = + Meter.CreateUpDownCounter(AhpTelemetryNames.SubscriptionsActive, unit: AhpTelemetryNames.SubscriptionsActiveUnit, + description: AhpTelemetryNames.SubscriptionsActiveDescription); + + internal static readonly Counter Reconnects = + Meter.CreateCounter(AhpTelemetryNames.Reconnects, unit: AhpTelemetryNames.ReconnectsUnit, + description: AhpTelemetryNames.ReconnectsDescription); + + internal static readonly Counter DroppedEvents = + Meter.CreateCounter(AhpTelemetryNames.EventsDropped, unit: AhpTelemetryNames.EventsDroppedUnit, + description: AhpTelemetryNames.EventsDroppedDescription); + + internal static readonly Counter MalformedFrames = + Meter.CreateCounter(AhpTelemetryNames.FramesMalformed, unit: AhpTelemetryNames.FramesMalformedUnit, + description: AhpTelemetryNames.FramesMalformedDescription); +} 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..ef59f19f --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj @@ -0,0 +1,46 @@ + + + + net8.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..090c855b --- /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", TestContext.Current.CancellationToken); + + Assert.Equal("cid-1", await store.LoadAsync(new HostId("h1"), TestContext.Current.CancellationToken)); + // A host that was never stored has no client ID. + Assert.Null(await store.LoadAsync(new HostId("never-stored"), TestContext.Current.CancellationToken)); + } + + // ── 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", TestContext.Current.CancellationToken); + await store.StoreAsync(host, "cid-2", TestContext.Current.CancellationToken); + + // The second store for the same host wins; reads see the latest value. + Assert.Equal("cid-2", await store.LoadAsync(host, TestContext.Current.CancellationToken)); + } + + // ── 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..d494b777 --- /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, Snapshots = new() }; + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = msg.Request.Id, + Result = Ser.SerializeToElement(result), + } + }; + 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 = AhpClient.Connect(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 = AhpClient.Connect(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 = Ser.SerializeToElement(envelope), + } + }; + 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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(clientSide); + + // Closing the server end propagates as a receive error to the client. + await serverSide.CloseAsync(TestContext.Current.CancellationToken); + + // Client.Completion should fire within a reasonable time. + await client.Completion.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + Assert.NotNull(client.Error); + } + + // ── Idempotent shutdown ─────────────────────────────────────────────── + + [Fact] + public async Task ShutdownIsIdempotent() + { + var (clientSide, _) = MemTransport.CreatePair(); + var client = AhpClient.Connect(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(TestContext.Current.CancellationToken), TestContext.Current.CancellationToken); + } + 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 = Ser.SerializeToElement(result), + } + }; + 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 = Ser.SerializeToElement(envelope), + } + }; + 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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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", cancellationToken: TestContext.Current.CancellationToken)); + + await client.ShutdownAsync(TestContext.Current.CancellationToken); + } + + // 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 = AhpClient.Connect(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 = AhpClient.Connect(clientSide); + + await client.ShutdownAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + async () => await client.RequestAsync("initialize", null, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.InitializeAsync("x", cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.NotifyAsync("ping", null, TestContext.Current.CancellationToken)); + } + + // 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 = AhpClient.Connect(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 = AhpClient.Connect(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 = AhpClient.Connect(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(TestContext.Current.CancellationToken); + + // 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(TestContext.Current.CancellationToken); + } + + // 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, TestContext.Current.CancellationToken); + + Assert.Equal(0, transport.PingCount); + + await client.ShutdownAsync(TestContext.Current.CancellationToken); + } + + // 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/DependencyInjectionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/DependencyInjectionTests.cs new file mode 100644 index 00000000..05ca8be1 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/DependencyInjectionTests.cs @@ -0,0 +1,57 @@ +// Proves the DI integration: AddAgentHostProtocol registers the services with the +// right lifetimes, applies ClientConfig via IOptions, lets a consumer override a +// service (TryAdd), and the factory produces a working IAhpClient over a real +// transport. The provider is disposed via `await using` because MultiHostClient is +// IAsyncDisposable-only. +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol.Hosts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class DependencyInjectionTests +{ + [Fact] + public async Task AddAgentHostProtocol_RegistersServices_AndAppliesConfig() + { + var services = new ServiceCollection(); + services.AddAgentHostProtocol(cfg => cfg.DefaultRequestTimeout = TimeSpan.FromSeconds(7)); + await using var sp = services.BuildServiceProvider(); + + Assert.NotNull(sp.GetRequiredService()); + Assert.IsType(sp.GetRequiredService()); + Assert.NotNull(sp.GetRequiredService()); + Assert.NotNull(sp.GetRequiredService()); + Assert.Equal(TimeSpan.FromSeconds(7), sp.GetRequiredService>().Value.DefaultRequestTimeout); + } + + [Fact] + public async Task IAhpClientFactory_Produces_WorkingClient() + { + var services = new ServiceCollection(); + services.AddAgentHostProtocol(); + await using var sp = services.BuildServiceProvider(); + var factory = sp.GetRequiredService(); + + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = factory.Connect(clientSide); + Assert.Equal(ConnectionState.Connected, client.ConnectionState); + } + + [Fact] + public async Task AddAgentHostProtocol_PreservesPreRegisteredStore() + { + var custom = new InMemoryClientIdStore(); + var services = new ServiceCollection(); + services.AddSingleton(custom); + services.AddAgentHostProtocol(); + await using var sp = services.BuildServiceProvider(); + + Assert.Same(custom, sp.GetRequiredService()); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FakeHost.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FakeHost.cs new file mode 100644 index 00000000..76ad6f83 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FakeHost.cs @@ -0,0 +1,178 @@ +// Shared fake-host test harness. Collapses the near-identical +// while (true) { receive → decode → dispatch-by-method } +// server loops that the multi-host / client / hosts tests each hand-rolled +// into one declarative builder: +// +// await FakeHost.New() +// .OnInitialize((req, side, ct) => RespondInitializeAsync(side, req.Id, ct)) +// .On("listSessions", (req, side, ct) => RespondListSessionsAsync(side, req.Id, sessions, ct)) +// .OnReconnect((req, side, ct) => RespondReplayAsync(side, req.Id, ct)) +// .AfterInitialize((side, ct) => RepeatActionAsync(side, channel, seq, ct)) // optional +// .RunAsync(serverSide, ct); +// +// The loop itself — ReceiveAsync, DecodeMessage, the swallow-and-exit on a +// closed transport, and the post-initialize side task — lives here exactly +// once. Drives the REAL serializer over the REAL MemTransport; nothing is +// mocked. Response helpers use SystemTextJsonAhpSerializer.SerializeToElement +// (no JsonDocument.Parse(...).RootElement leak). +#nullable enable + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol.Tests; + +/// +/// Declarative fake AHP host: register per-method request handlers, then +/// drives the single canonical receive→decode→dispatch +/// loop. Reused by the host / multi-host / client tests. Internal because it +/// takes the internal MemTransport test helper. +/// +internal sealed class FakeHost +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + /// A handler for one inbound JSON-RPC request. + public delegate Task RequestHandler(JsonRpcRequest request, MemTransport serverSide, CancellationToken ct); + + /// A side task started once, right after the first `initialize`. + public delegate Task PostInitialize(MemTransport serverSide, CancellationToken ct); + + private readonly Dictionary _handlers = new(StringComparer.Ordinal); + private RequestHandler? _default; + private PostInitialize? _afterInitialize; + + private FakeHost() { } + + /// Starts a new, empty fake-host definition. + public static FakeHost New() => new(); + + /// Registers the handler for . + public FakeHost On(string method, RequestHandler handler) + { + _handlers[method] = handler; + return this; + } + + /// Registers the initialize handler. + public FakeHost OnInitialize(RequestHandler handler) => On("initialize", handler); + + /// Registers the listSessions handler. + public FakeHost OnListSessions(RequestHandler handler) => On("listSessions", handler); + + /// Registers the reconnect handler. + public FakeHost OnReconnect(RequestHandler handler) => On("reconnect", handler); + + /// + /// Registers the fallback handler for any request whose method has no + /// explicit handler. Without one, unmatched requests are ignored. + /// + public FakeHost OnDefault(RequestHandler handler) + { + _default = handler; + return this; + } + + /// + /// Convenience fallback: acknowledge any otherwise-unhandled request with an + /// empty {} result so the client's pending entry resolves. + /// + public FakeHost AckUnmatchedWithEmpty() => OnDefault((req, side, ct) => RespondEmptyAsync(side, req.Id, ct)); + + /// + /// Registers a side task started once, immediately after the first + /// initialize is answered (e.g. a repeated notification push). It is + /// fire-and-forget and shares the loop's cancellation token. + /// + public FakeHost AfterInitialize(PostInitialize hook) + { + _afterInitialize = hook; + return this; + } + + /// + /// Runs the canonical server loop against : + /// receive a frame, decode it, dispatch to the matching handler (or the + /// default), and — once — fire the post-initialize hook. Returns when the + /// transport closes or the token is cancelled. + /// + public async Task RunAsync(MemTransport serverSide, CancellationToken ct) + { + var firedAfterInit = false; + 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 not { } request) + continue; + + if (_handlers.TryGetValue(request.Method, out RequestHandler? handler)) + await handler(request, serverSide, ct).ConfigureAwait(false); + else if (_default is not null) + await _default(request, serverSide, ct).ConfigureAwait(false); + + if (request.Method == "initialize" && !firedAfterInit && _afterInitialize is { } hook) + { + firedAfterInit = true; + _ = Task.Run(() => hook(serverSide, ct)); + } + } + } + catch (OperationCanceledException) { } + } + + // ── Shared response helpers (real serializer; no JsonDocument leak) ───── + + /// Replies to with serialized. + public static async Task RespondResultAsync(MemTransport serverSide, ulong id, T result, CancellationToken ct) + { + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = id, + Result = Ser.SerializeToElement(result), + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + /// Replies to with an empty {} result. + public static async Task RespondEmptyAsync(MemTransport serverSide, ulong id, CancellationToken ct) + { + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = id, + Result = JsonSerializer.SerializeToElement(new Dictionary()), + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(response), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } + + /// Sends a notification carrying as its params. + public static async Task SendNotificationAsync(MemTransport serverSide, string method, T @params, CancellationToken ct) + { + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = method, + Params = Ser.SerializeToElement(@params), + }, + }; + try { await serverSide.SendAsync(Ser.EncodeMessage(notif), ct).ConfigureAwait(false); } + catch { /* peer gone */ } + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs new file mode 100644 index 00000000..d05ddc5e --- /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"), TestContext.Current.CancellationToken)); + + // Store then load within the same instance round-trips the value. + await writer.StoreAsync(new HostId("alpha"), "abc-123", TestContext.Current.CancellationToken); + Assert.Equal("abc-123", await writer.LoadAsync(new HostId("alpha"), TestContext.Current.CancellationToken)); + + // Overwrite: the second store for the same host wins. + await writer.StoreAsync(new HostId("alpha"), "abc-456", TestContext.Current.CancellationToken); + Assert.Equal("abc-456", await writer.LoadAsync(new HostId("alpha"), TestContext.Current.CancellationToken)); + + // 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"), TestContext.Current.CancellationToken)); + } + + // ── 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", TestContext.Current.CancellationToken); + await store.StoreAsync(new HostId("b"), "id-b", TestContext.Current.CancellationToken); + + // Each host keeps its own value — storing "b" doesn't clobber "a". + Assert.Equal("id-a", await store.LoadAsync(new HostId("a"), TestContext.Current.CancellationToken)); + Assert.Equal("id-b", await store.LoadAsync(new HostId("b"), TestContext.Current.CancellationToken)); + } + + // ── 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", TestContext.Current.CancellationToken); + + Assert.Equal("tricky-id", await store.LoadAsync(trickyId, TestContext.Current.CancellationToken)); + // 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", TestContext.Current.CancellationToken); + Assert.Equal("other-id", await store.LoadAsync(otherId, TestContext.Current.CancellationToken)); + Assert.Equal("tricky-id", await store.LoadAsync(trickyId, TestContext.Current.CancellationToken)); + } + + // ── 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}", TestContext.Current.CancellationToken), TestContext.Current.CancellationToken); + } + await Task.WhenAll(writes); + + for (var i = 0; i < writes.Length; i++) + { + var value = await store.LoadAsync(new HostId($"h-{i}"), TestContext.Current.CancellationToken); + 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", TestContext.Current.CancellationToken); + + // The value persisted regardless of platform. + Assert.Equal("value", await store.LoadAsync(new HostId("h"), TestContext.Current.CancellationToken)); + + // 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", TestContext.Current.CancellationToken); + + 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", TestContext.Current.CancellationToken)); + + 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, TestContext.Current.CancellationToken)); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs new file mode 100644 index 00000000..a2e2b135 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs @@ -0,0 +1,330 @@ +// Regression tests pinning the behaviors fixed after the adversarial review, so a +// future refactor that reintroduces a bug FAILS here rather than silently shipping. +// Each test maps to a confirmed finding from the review. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class FixRegressionTests +{ + // ── Subscription lifecycle: Close()/Dispose() must detach, regardless of teardown + // path (the subscriptions.active-gauge desync + _subscriptions registry leak fix). ── + + [Fact] + public void Subscription_Close_RunsDetachHookExactlyOnce() + { + int detached = 0; + var sub = new Subscription("ahp-session:/s1", 8); + sub.OnClose(() => Interlocked.Increment(ref detached)); + sub.Close(); + sub.Close(); // idempotent + sub.Dispose(); + Assert.Equal(1, detached); + } + + [Fact] + public async Task DirectSubscriptionClose_DetachesFromClientRegistry() + { + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + + var sub = client.AttachSubscription("ahp-session:/s1"); + Assert.Equal(1, client.SubscriptionCount); + + sub.Close(); // a direct Close() (not UnsubscribeAsync) must still detach + Assert.Equal(0, client.SubscriptionCount); + } + + // ── Back-pressure: each drop-oldest eviction is counted EXACTLY once via the BCL + // ItemDropped callback (replacing the racy Count-then-write probe). ── + + [Fact] + public void BoundedDropOldestChannel_ReportsEachEvictionExactlyOnce() + { + int dropped = 0; + var channel = new BoundedDropOldestChannel(2, _ => Interlocked.Increment(ref dropped)); + for (int i = 0; i < 5; i++) channel.TrySend(i); // capacity 2, 5 sends, no reader -> 3 evictions + Assert.Equal(3, dropped); + } + + // ── ClientConfig.Default is a fresh instance per access (no cross-consumer bleed). ── + + [Fact] + public void ClientConfigDefault_ReturnsDistinctInstances() + { + var a = ClientConfig.Default; + var b = ClientConfig.Default; + Assert.NotSame(a, b); + a.DefaultRequestTimeout = TimeSpan.FromSeconds(99); + Assert.NotEqual(a.DefaultRequestTimeout, b.DefaultRequestTimeout); + } + + // ── A request timeout is recorded as ahp.outcome="timeout", distinct from a caller + // cancellation ("cancelled") or a success ("ok"). ── + + [Fact] + public async Task RequestTimeout_RecordsOutcomeTimeout() + { + var sawTimeout = false; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, _, tags, _) => + { + if (inst.Name != "ahp.client.request.duration") return; + foreach (var tag in tags) + if (tag.Key == "ahp.outcome" && (tag.Value as string) == "timeout") sawTimeout = true; + }); + meterListener.Start(); + + var (clientSide, _) = MemTransport.CreatePair(); // server never replies + var cfg = new ClientConfig { DefaultRequestTimeout = TimeSpan.FromMilliseconds(50) }; + await using var client = AhpClient.Connect(clientSide, cfg); + + await Assert.ThrowsAnyAsync(() => + client.RequestAsync("noop", null, TestContext.Current.CancellationToken)); + + Assert.True(sawTimeout, "request.duration should carry ahp.outcome=timeout when the default timeout fires"); + } + + // ── Pre-existing fix: HostEntry.ApplySummaryChange is copy-on-write, so a snapshot + // already handed to a consumer is never mutated underneath it (torn-read fix). ── + + [Fact] + public void ApplySummaryChange_DoesNotMutate_AlreadyTakenSnapshot() + { + var entry = new HostEntry(new HostId("h"), new HostConfig { Id = new HostId("h") }, "client-1"); + entry.PutSessionSummary(new SessionSummary + { + Resource = "ahp-session:/s1", + Provider = "p", + Title = "Original", + CreatedAt = 1, + ModifiedAt = 1, + }); + + var held = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1"); + Assert.Equal("Original", held.Title); + + entry.ApplySummaryChange("ahp-session:/s1", new PartialSessionSummary { Title = "Changed" }); + + Assert.Equal("Original", held.Title); // copy-on-write: the prior snapshot is immutable + Assert.Equal("Changed", + entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1").Title); + } + + // ── Upstream #254: SessionSummary._meta is a patchable field on + // root/sessionSummaryChanged — the merge overrides it when the patch carries + // it and otherwise carries the existing value over (mirrors the TS reducer's + // `if (changes._meta !== undefined) merged._meta = changes._meta`). ── + + [Fact] + public void ApplySummaryChange_Meta_OverridesWhenPresent_CarriesOverWhenAbsent() + { + var entry = new HostEntry(new HostId("h"), new HostConfig { Id = new HostId("h") }, "client-1"); + var originalMeta = new Dictionary + { + ["pinned"] = JsonDocument.Parse("true").RootElement, + }; + entry.PutSessionSummary(new SessionSummary + { + Resource = "ahp-session:/s1", + Provider = "p", + Title = "Original", + CreatedAt = 1, + ModifiedAt = 1, + Meta = originalMeta, + }); + + // Patch that omits _meta keeps the existing metadata. + entry.ApplySummaryChange("ahp-session:/s1", new PartialSessionSummary { Title = "Changed" }); + var afterTitleOnly = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1"); + Assert.Equal("Changed", afterTitleOnly.Title); + Assert.NotNull(afterTitleOnly.Meta); + Assert.True(afterTitleOnly.Meta!["pinned"].GetBoolean()); + + // Patch that carries _meta overrides it. + var newMeta = new Dictionary + { + ["pinned"] = JsonDocument.Parse("false").RootElement, + }; + entry.ApplySummaryChange("ahp-session:/s1", new PartialSessionSummary { Meta = newMeta }); + var afterMetaPatch = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1"); + Assert.NotNull(afterMetaPatch.Meta); + Assert.False(afterMetaPatch.Meta!["pinned"].GetBoolean()); + } + + // ── Upstream drift port (model config widened to JSON primitives; SessionModelInfo + // token-limit fields). ModelSelection.Config + ConfigPropertySchema.Enum carry + // arbitrary JSON primitives (not just strings), so a numeric/boolean picker value + // must round-trip as-is. Falsifies a revert to Dictionary / + // List (which can't hold a number) or a drop of the new token fields. ── + + [Fact] + public void ModelSelection_Config_CarriesNonStringJsonPrimitives() + { + var selection = new ModelSelection + { + Id = "gpt-5", + Config = new Dictionary + { + ["preset"] = JsonDocument.Parse("\"fast\"").RootElement, + ["temperature"] = JsonDocument.Parse("0.7").RootElement, + ["stream"] = JsonDocument.Parse("true").RootElement, + }, + }; + + string json = SystemTextJsonAhpSerializer.Default.Serialize(selection); + var back = SystemTextJsonAhpSerializer.Default.Deserialize(json); + + Assert.NotNull(back!.Config); + Assert.Equal("fast", back.Config!["preset"].GetString()); + Assert.Equal(0.7, back.Config["temperature"].GetDouble()); + Assert.True(back.Config["stream"].GetBoolean()); + } + + [Fact] + public void SessionModelInfo_RoundTripsOutputAndPromptTokenLimits() + { + var info = new SessionModelInfo + { + Id = "gpt-5", + Provider = "openai", + Name = "GPT-5", + MaxContextWindow = 200_000, + MaxOutputTokens = 32_000, + MaxPromptTokens = 168_000, + }; + + string json = SystemTextJsonAhpSerializer.Default.Serialize(info); + var back = SystemTextJsonAhpSerializer.Default.Deserialize(json); + + Assert.Equal(32_000, back!.MaxOutputTokens); + Assert.Equal(168_000, back.MaxPromptTokens); + Assert.Equal(200_000, back.MaxContextWindow); + } + + // ── Pre-existing fix: MultiHostStateMirror carries the Chat dimension (Go parity), + // and both drop paths (DropResource, DropHost) cover it. ── + + [Fact] + public void MultiHostStateMirror_StoresAndDropsChatSnapshots() + { + var mirror = new MultiHostStateMirror(); + var chat = new ChatState { Resource = "ahp-session:/s1#chat", Title = "c1", ModifiedAt = "0", Turns = new() }; + + mirror.PutChat("host-a", "ahp-session:/s1#chat", chat); + Assert.True(mirror.Chat("host-a", "ahp-session:/s1#chat").Found); + Assert.Same(chat, mirror.Chat("host-a", "ahp-session:/s1#chat").Value); + + mirror.DropResource("host-a", "ahp-session:/s1#chat"); + Assert.False(mirror.Chat("host-a", "ahp-session:/s1#chat").Found); + + mirror.PutChat("host-b", "ahp-session:/s2#chat", chat); + mirror.DropHost("host-b"); + Assert.False(mirror.Chat("host-b", "ahp-session:/s2#chat").Found); + } + + // ── Pre-existing fix: CreateEventStream / CreateStateChangeStream detach on dispose, + // so an abandoned stream leaves the client's fan-out list (no per-stream leak). ── + + [Fact] + public async Task EventStream_Dispose_DetachesFromClientFanout() + { + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + + var stream = client.CreateEventStream(); + Assert.Equal(1, client.EventListenerCount); + + stream.Dispose(); + Assert.Equal(0, client.EventListenerCount); // detached, not leaked + } + + [Fact] + public async Task StateChangeStream_Dispose_DetachesFromClientFanout() + { + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + + var stream = client.CreateStateChangeStream(); + Assert.Equal(1, client.StateListenerCount); + + stream.Dispose(); + Assert.Equal(0, client.StateListenerCount); // detached, not leaked + } + + // ── Multiple active clients per session (microsoft/agent-host-protocol#261): + // activeClient? -> activeClients[]. Sequential session/activeClientSet upserts + // keyed by clientId build a multi-client list; session/activeClientRemoved + // removes by clientId (no-op on miss). Falsifies a revert to a single-value + // field or a broken upsert that appends duplicates instead of replacing. ── + [Fact] + public void SessionActiveClients_SetUpsertsByClientId_RemoveDropsByClientId() + { + var state = new SessionState + { + Summary = new SessionSummary { Title = "s", Resource = "ahp-session:/s", Provider = "copilot" }, + Lifecycle = SessionLifecycle.Ready, + ActiveClients = new(), + Chats = new(), + }; + + SessionActiveClient Client(string id, string name) => + new() { ClientId = id, DisplayName = name, Tools = new() }; + + // SET a, then SET b — both coexist (the headline #261 capability). + Reducers.ApplyToSession(state, new StateAction(new SessionActiveClientSetAction + { + Type = ActionType.SessionActiveClientSet, + ActiveClient = Client("vscode-1", "VS Code"), + })); + Reducers.ApplyToSession(state, new StateAction(new SessionActiveClientSetAction + { + Type = ActionType.SessionActiveClientSet, + ActiveClient = Client("cli-1", "CLI"), + })); + Assert.Equal(new[] { "vscode-1", "cli-1" }, state.ActiveClients.Select(c => c.ClientId)); + + // SET vscode-1 again — upsert replaces in place (length stays 2, not 3). + Reducers.ApplyToSession(state, new StateAction(new SessionActiveClientSetAction + { + Type = ActionType.SessionActiveClientSet, + ActiveClient = Client("vscode-1", "VS Code Insiders"), + })); + Assert.Equal(2, state.ActiveClients.Count); + Assert.Equal("VS Code Insiders", state.ActiveClients.Single(c => c.ClientId == "vscode-1").DisplayName); + + // REMOVE vscode-1 — leaves cli-1. + var removed = Reducers.ApplyToSession(state, new StateAction(new SessionActiveClientRemovedAction + { + Type = ActionType.SessionActiveClientRemoved, + ClientId = "vscode-1", + })); + Assert.Equal(ReduceOutcome.Applied, removed); + Assert.Equal(new[] { "cli-1" }, state.ActiveClients.Select(c => c.ClientId)); + + // REMOVE unknown — no-op, list unchanged. + var noop = Reducers.ApplyToSession(state, new StateAction(new SessionActiveClientRemovedAction + { + Type = ActionType.SessionActiveClientRemoved, + ClientId = "ghost", + })); + Assert.Equal(ReduceOutcome.NoOp, noop); + Assert.Single(state.ActiveClients); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs new file mode 100644 index 00000000..be989cc2 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs @@ -0,0 +1,207 @@ +#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; + case "chat": + RunFixture(initial, expected, actions, Reducers.ApplyToChat); + 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(JsonSerializer.SerializeToElement(state, Options)); + 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/HostStreamDropTagTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs new file mode 100644 index 00000000..4e1c9979 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs @@ -0,0 +1,175 @@ +// Regression guard for the multi-host drop-tag refactor (PR #206 telemetry pass): +// +// 1. SOURCE GATE — MultiHostClient.cs must NOT carry any raw "host-*" string +// literal. Every per-host stream's drop tag must route the ahp.stream value +// through the generated AhpTelemetryNames.StreamHost* constants (cached as a +// static KeyValuePair, like the single-host AhpClient/Subscription drop path), +// not an inline literal. A raw literal here silently drifts from the generated +// contract the moment a name changes. +// +// 2. RUNTIME PROOF — a real back-pressure drop on a per-host stream fires the +// events.dropped counter tagged with the host-* value carried by the constant, +// proving the constant resolves to the expected wire value end-to-end. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class HostStreamDropTagTests +{ + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + // The five per-host stream wire values, asserted to (a) be absent as raw + // literals in the source and (b) equal the generated StreamHost* constants. + private static readonly (string Constant, string WireValue)[] HostStreamTags = + { + (AhpTelemetryNames.StreamHostEvent, "host-event"), + (AhpTelemetryNames.StreamHostSubscription, "host-subscription"), + (AhpTelemetryNames.StreamHostResource, "host-resource"), + (AhpTelemetryNames.StreamHostSnapshot, "host-snapshot"), + (AhpTelemetryNames.StreamHostSummaries, "host-summaries"), + }; + + [Fact] + public void GeneratedConstants_CarryTheExpectedHostStreamWireValues() + { + // The constants must resolve to the host-* wire values the registry defines. + foreach (var (constant, wire) in HostStreamTags) + Assert.Equal(wire, constant); + } + + [Fact] + public void MultiHostClientSource_HasNoRawHostStreamLiterals() + { + var source = File.ReadAllText(FindMultiHostClientSource()); + + // No raw "host-..." quoted literal may remain anywhere in MultiHostClient.cs. + // After the refactor the drop tags reference AhpTelemetryNames.StreamHost* + // (and are cached as static KeyValuePairs), so the only host-* text in the + // file is inside comments — never a `"host-..."` string literal. + foreach (var (_, wire) in HostStreamTags) + { + var literal = "\"" + wire + "\""; + Assert.DoesNotContain(literal, source, StringComparison.Ordinal); + } + } + + [Fact] + public async Task HostSnapshotsBackPressure_FiresDroppedCounterTaggedHostSnapshot() + { + // Drive a REAL drop on the per-host snapshot stream (capacity-1 DropOldest): + // overfilling it without a reader evicts the stalest snapshot and fires + // events.dropped tagged ahp.stream=host-snapshot — carried by the constant. + long drops = 0; + var streams = new List(); + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, tags, _) => + { + if (inst.Name != AhpTelemetryNames.EventsDropped) return; + var stream = TagValue(tags, AhpTelemetryNames.AttrStream); + // Only count the host-snapshot stream so this test is robust to other + // tests' drops on the process-wide static Meter. + if (stream == AhpTelemetryNames.StreamHostSnapshot) + { + Interlocked.Add(ref drops, measurement); + lock (streams) streams.Add(stream); + } + }); + meterListener.Start(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var m = new MultiHostClient(); + await using var disposeMulti = m; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-a"), + TransportFactory = (id, ct) => + { + var (c, s) = MemTransport.CreatePair(); + _ = Task.Run(() => RespondInitializeLoopAsync(s, ct)); + return Task.FromResult(c); + }, + }, cts.Token); + + // Register a snapshot stream but never read it. The capacity-1 channel gets + // one snapshot immediately (the initial Snapshot()); each subsequent host + // observable change (a manual reconnect transitions state) evicts the prior. + var snapshots = m.HostSnapshots(new HostId("host-a")); + _ = snapshots; // intentionally undrained — a stalled consumer + + // Trigger several observable state changes to push past the 1-slot buffer. + for (var i = 0; i < 6; i++) + { + await m.ReconnectAsync(new HostId("host-a"), cts.Token); + await Task.Delay(20, cts.Token); + } + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (Interlocked.Read(ref drops) < 1 && DateTime.UtcNow < deadline) + await Task.Delay(20, cts.Token); + + Assert.True(Interlocked.Read(ref drops) >= 1, + "expected ≥1 ahp.client.events.dropped tagged ahp.stream=host-snapshot under back-pressure"); + string[] snapshot; + lock (streams) snapshot = streams.ToArray(); + Assert.All(snapshot, s => Assert.Equal(AhpTelemetryNames.StreamHostSnapshot, s)); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private static string? TagValue(ReadOnlySpan> tags, string key) + { + foreach (var tag in tags) + if (tag.Key == key) return tag.Value as string; + return null; + } + + /// Answers every `initialize` request on the transport until cancelled. + private static async Task RespondInitializeLoopAsync(MemTransport serverSide, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + var frame = await serverSide.ReceiveAsync(ct).ConfigureAwait(false); + var msg = Ser.DecodeMessage(frame); + if (msg.Request?.Method == "initialize") + await FakeHost.RespondResultAsync( + serverSide, msg.Request.Id, + new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, + ct).ConfigureAwait(false); + } + } + catch { /* transport closed / cancelled — expected at teardown */ } + } + + private static string FindMultiHostClientSource() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine( + dir, "src", "AgentHostProtocol", "Hosts", "MultiHostClient.cs"); + if (File.Exists(candidate)) return candidate; + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + throw new FileNotFoundException( + "could not locate src/AgentHostProtocol/Hosts/MultiHostClient.cs 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..48683571 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs @@ -0,0 +1,88 @@ +// Port of clients/go/ahp/hosts/hosts_test.go. +// Uses the same in-memory transport pair from ClientTests. +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class HostsTests +{ + // ── Fake server helper (mirrors hosts_test.go / runFakeServer) ──────── + // Uses the shared FakeHost loop; the per-test logic is just the initialize + // reply. + + private static Task RunFakeServerAsync(MemTransport serverSide, CancellationToken ct) => + FakeHost.New() + .OnInitialize((req, side, c) => FakeHost.RespondResultAsync( + side, req.Id, new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, c)) + .RunAsync(serverSide, ct); + + // ── 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), TestContext.Current.CancellationToken); + + 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/IAhpClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/IAhpClientTests.cs new file mode 100644 index 00000000..b6f85edf --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/IAhpClientTests.cs @@ -0,0 +1,23 @@ +// Proves the client is substitutable behind IAhpClient — consumers depend on the +// interface (and can mock it) rather than the concrete sealed AhpClient. +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class IAhpClientTests +{ + [Fact] + public async Task AhpClient_IsSubstitutable_BehindIAhpClient() + { + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + + IAhpClient asInterface = Assert.IsAssignableFrom(client); + Assert.Equal(ConnectionState.Connected, asInterface.ConnectionState); + } +} 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..396b0f8c --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs @@ -0,0 +1,2363 @@ +// 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 ─────────────────────────────────────────────── + // The receive→decode→dispatch loop lives once in FakeHost; these helpers + // build the per-test FakeHost definitions and the response payloads. + + /// + /// Server loop that answers initialize. Mirrors HostsTests.RunFakeServer. + /// + private static Task RunFakeServerAsync(MemTransport serverSide, CancellationToken ct) => + FakeHost.New() + .OnInitialize((req, side, c) => RespondInitializeAsync(side, req.Id, c)) + .RunAsync(serverSide, ct); + + /// + /// 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. The reconnect handler + /// replays an action at the (advanced) serverSeq, mirroring a host that + /// resumes from the gap. + /// + private static Task RunFakeServerWithActionAsync( + MemTransport serverSide, + string actionChannel, + long serverSeq, + CancellationToken ct) => + FakeHost.New() + .OnInitialize((req, side, c) => RespondInitializeAsync(side, req.Id, c)) + .AfterInitialize((side, c) => RepeatActionAsync(side, actionChannel, serverSeq, c)) + .OnReconnect((req, side, c) => + { + var replay = new ReconnectReplayResult + { + Actions = new List + { + new ActionEnvelope + { + Channel = actionChannel, + ServerSeq = serverSeq, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 7, + }), + }, + }, + Missing = new List(), + }; + return FakeHost.RespondResultAsync(side, req.Id, new ReconnectResult(replay), c); + }) + .RunAsync(serverSide, ct); + + /// 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 Task RespondInitializeAsync(MemTransport serverSide, ulong id, CancellationToken ct) => + FakeHost.RespondResultAsync( + serverSide, id, new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, ct); + + private static Task SendActionAsync( + MemTransport serverSide, string channel, long serverSeq, CancellationToken ct) => + FakeHost.SendNotificationAsync(serverSide, "action", new ActionEnvelope + { + Channel = channel, + ServerSeq = serverSeq, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = $"seq-{serverSeq}", + }), + }, ct); + + // ── 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 Task RunFakeServerFullAsync( + MemTransport serverSide, + IReadOnlyList? sessions = null, + IReadOnlyList? agents = null, + long activeSessions = 0, + SessionSummary? injectAfterInit = null, + CancellationToken ct = default) + { + var host = FakeHost.New() + .OnInitialize((req, side, c) => RespondInitializeWithRootAsync(side, req.Id, agents, activeSessions, c)) + .OnListSessions((req, side, c) => RespondListSessionsAsync(side, req.Id, sessions ?? Array.Empty(), c)) + // Acknowledge any other request with an empty object so the client's + // pending entry resolves. + .AckUnmatchedWithEmpty(); + if (injectAfterInit is not null) + host.AfterInitialize((side, c) => RepeatSessionAddedAsync(side, injectAfterInit, c)); + return host.RunAsync(serverSide, ct); + } + + /// + /// 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 Task RunReconnectFakeServerAsync( + MemTransport serverSide, + long replaySeq, + string[] missing, + CancellationToken ct = default) => + FakeHost.New() + .OnInitialize((req, side, c) => RespondInitializeWithRootAsync(side, req.Id, null, 1, c)) + .OnListSessions((req, side, c) => RespondListSessionsAsync(side, req.Id, Array.Empty(), c)) + .OnReconnect((req, side, c) => + { + var replay = new ReconnectReplayResult + { + Actions = new List + { + new ActionEnvelope + { + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = replaySeq, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 7, + }), + }, + }, + Missing = new List(missing), + }; + return FakeHost.RespondResultAsync(side, req.Id, new ReconnectResult(replay), c); + }) + .AckUnmatchedWithEmpty() + .RunAsync(serverSide, ct); + + private static Task RespondInitializeWithRootAsync( + MemTransport serverSide, ulong id, IReadOnlyList? agents, long activeSessions, CancellationToken ct) + { + var result = new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, + ServerSeq = 0, + Snapshots = new List + { + new Snapshot + { + Resource = ProtocolVersion.RootResourceUri, + FromSeq = 0, + State = new SnapshotState + { + Root = new RootState + { + Agents = agents is not null + ? new List(agents) + : new List(), + ActiveSessions = activeSessions, + }, + }, + }, + }, + }; + return FakeHost.RespondResultAsync(serverSide, id, result, ct); + } + + private static Task RespondListSessionsAsync( + MemTransport serverSide, ulong id, IReadOnlyList sessions, CancellationToken ct) => + FakeHost.RespondResultAsync( + serverSide, id, new ListSessionsResult { Items = new List(sessions) }, ct); + + // Thin aliases for the few tests (#9, #10, #15) that hand-roll a bespoke + // connect-then-drop loop the declarative builder doesn't model. + private static Task RespondEmptyAsync(MemTransport serverSide, ulong id, CancellationToken ct) => + FakeHost.RespondEmptyAsync(serverSide, id, ct); + + private static Task RepeatSessionAddedAsync(MemTransport serverSide, SessionSummary summary, CancellationToken ct) + { + return Loop(); + + async Task Loop() + { + try + { + while (!ct.IsCancellationRequested) + { + await FakeHost.SendNotificationAsync(serverSide, "root/sessionAdded", + new SessionAddedParams { Channel = ProtocolVersion.RootResourceUri, Summary = summary }, ct).ConfigureAwait(false); + await Task.Delay(25, ct).ConfigureAwait(false); + } + } + catch { /* cancelled or 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 = m.ReconnectAllUnavailable(); + 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 = m.ReconnectAllUnavailable(); + // 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..ce40e4c6 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs @@ -0,0 +1,193 @@ +// 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, Resource = "ahp-session:/" + title, Provider = "copilot" }, + Lifecycle = SessionLifecycle.Ready, + ActiveClients = new(), + Chats = new(), + }; + + // ── 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..0b4cb9c8 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs @@ -0,0 +1,49 @@ +// 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 Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class NativeReducerTests +{ + // A: clientDispatchable true — a chat-channel action (chat/turnStarted) is dispatchable. + [Fact] + public void ClientDispatchable_TrueForUserChannelAction() + { + var action = new StateAction(new ChatTurnStartedAction + { + Type = ActionType.ChatTurnStarted, + TurnId = "t1", + // Message.Origin is a required (non-nullable) MessageOrigin — give it a + // valid value so the action serializes. + Message = new Message + { + Text = "hi", + Origin = new MessageOrigin { Kind = MessageKind.User }, + }, + }); + 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/SnapshotStateUnionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/SnapshotStateUnionTests.cs new file mode 100644 index 00000000..efb76785 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/SnapshotStateUnionTests.cs @@ -0,0 +1,138 @@ +// SnapshotState is the shape-probed discriminated union of the six per-channel +// state types (root, session, terminal, changeset, resource-watch, annotations). +// The generated SnapshotStateConverter inspects distinctive top-level wire fields +// to pick the variant. These tests round-trip a SnapshotState through the REAL +// serializer (AhpJson.Options, the same options the production client uses) for +// every variant and assert the result deserializes back to the CORRECT variant — +// in particular that ResourceWatchState and AnnotationsState are NOT silently +// mis-routed to RootState (the fallback branch), which is what a converter that +// only probes session/terminal/changeset/root would do. +#nullable enable + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class SnapshotStateUnionTests +{ + private static SnapshotState RoundTrip(SnapshotState value) + { + // Serialize through the real converter, then read it straight back. A + // correct converter recovers the same variant; a converter missing a + // probe drops the payload into the RootState fallback branch. + string wire = JsonSerializer.Serialize(value, AhpJson.Options); + return JsonSerializer.Deserialize(wire, AhpJson.Options)!; + } + + [Fact] + public void ResourceWatchState_RoundTripsToResourceWatchVariant() + { + var original = new SnapshotState + { + ResourceWatch = new ResourceWatchState + { + Root = "ahp-resource-watch://workspace/src", + Recursive = true, + }, + }; + + SnapshotState decoded = RoundTrip(original); + + // The decisive assertion: the ResourceWatch variant is recovered and the + // payload was NOT mis-typed as RootState (the catch-all fallback). + Assert.NotNull(decoded.ResourceWatch); + Assert.Null(decoded.Root); + Assert.Null(decoded.Session); + Assert.Null(decoded.Terminal); + Assert.Null(decoded.Changeset); + Assert.Null(decoded.Annotations); + + Assert.Equal("ahp-resource-watch://workspace/src", decoded.ResourceWatch!.Root); + Assert.True(decoded.ResourceWatch.Recursive); + } + + [Fact] + public void AnnotationsState_RoundTripsToAnnotationsVariant() + { + var original = new SnapshotState + { + Annotations = new AnnotationsState + { + Annotations = new List + { + new Annotation + { + Id = "ann-1", + TurnId = "turn-1", + Resource = "ahp-session:/00000000-0000-0000-0000-000000000000/file.cs", + Resolved = false, + Entries = new List + { + new AnnotationEntry { Id = "entry-1", Text = StringOrMarkdown.FromPlain("first note") }, + }, + }, + }, + }, + }; + + SnapshotState decoded = RoundTrip(original); + + // The decisive assertion: the Annotations variant is recovered and the + // payload was NOT mis-typed as RootState (the catch-all fallback). + Assert.NotNull(decoded.Annotations); + Assert.Null(decoded.Root); + Assert.Null(decoded.Session); + Assert.Null(decoded.Terminal); + Assert.Null(decoded.Changeset); + Assert.Null(decoded.ResourceWatch); + + Assert.Single(decoded.Annotations!.Annotations); + Assert.Equal("ann-1", decoded.Annotations.Annotations[0].Id); + Assert.Equal("first note", decoded.Annotations.Annotations[0].Entries[0].Text.AsText()); + } + + // Guard the other four variants too, so a future re-ordering of the probe + // chain that shadows an existing variant is caught here as well. + [Fact] + public void RootState_RoundTripsToRootVariant() + { + var original = new SnapshotState + { + Root = new RootState { Agents = new List() }, + }; + + SnapshotState decoded = RoundTrip(original); + + Assert.NotNull(decoded.Root); + Assert.Null(decoded.ResourceWatch); + Assert.Null(decoded.Annotations); + Assert.Null(decoded.Session); + Assert.Null(decoded.Terminal); + Assert.Null(decoded.Changeset); + } + + [Fact] + public void ChangesetState_RoundTripsToChangesetVariant() + { + var original = new SnapshotState + { + Changeset = new ChangesetState + { + Status = ChangesetStatus.Ready, + Files = new List(), + }, + }; + + SnapshotState decoded = RoundTrip(original); + + Assert.NotNull(decoded.Changeset); + Assert.Null(decoded.Root); + Assert.Null(decoded.ResourceWatch); + Assert.Null(decoded.Annotations); + Assert.Null(decoded.Session); + Assert.Null(decoded.Terminal); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs new file mode 100644 index 00000000..06d74b5a --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs @@ -0,0 +1,477 @@ +// Proves the OpenTelemetry-native instrumentation actually EMITS (not just +// compiles): an ActivityListener captures the request span and a MeterListener +// captures the metrics, driven through a real InitializeAsync round-trip over +// the in-memory transport (MemTransport / FakeServer from ClientTests). +// Assertions are "at least one matching" so the signal — which flows through the +// process-wide static ActivitySource/Meter — is robust to other test classes +// running in parallel: it proves the instrumentation fires, not that it is the +// only emitter. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol.Hosts; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class TelemetryTests +{ + [Fact] + public async Task Request_EmitsActivitySpan_WithRpcTags() + { + var spans = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = src => src.Name == AhpTelemetry.Name, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => { lock (spans) spans.Add(a); }, + }; + ActivitySource.AddActivityListener(listener); + + await DriveOneInitializeAsync(); + + Activity[] snapshot; + lock (spans) snapshot = spans.ToArray(); + // Span name follows the OTel "{operation} {target}" shape, e.g. "ahp.request initialize". + Assert.Contains(snapshot, a => + a.OperationName == $"{AhpTelemetryNames.RequestSpan} initialize" + && a.Kind == ActivityKind.Client + && (a.GetTagItem(AhpTelemetryNames.AttrRpcSystem) as string) == AhpTelemetryNames.RpcSystemJsonrpc + && (a.GetTagItem(AhpTelemetryNames.AttrRpcMethod) as string) == "initialize" + && a.Status == ActivityStatusCode.Ok); + } + + [Fact] + public async Task Request_RecordsSentAndDurationMetrics() + { + long messagesSent = 0; + long durationSamples = 0; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, _, _) => + { + if (inst.Name == AhpTelemetryNames.MessagesSent) Interlocked.Add(ref messagesSent, measurement); + }); + meterListener.SetMeasurementEventCallback((inst, _, _, _) => + { + if (inst.Name == AhpTelemetryNames.RequestDuration) Interlocked.Increment(ref durationSamples); + }); + meterListener.Start(); + + await DriveOneInitializeAsync(); + + Assert.True(Interlocked.Read(ref messagesSent) >= 1, "expected an ahp.client.messages.sent measurement"); + Assert.True(Interlocked.Read(ref durationSamples) >= 1, "expected an ahp.client.request.duration measurement"); + } + + [Fact] + public async Task Initialize_EmitsReceivedAndInflightMetrics() + { + long messagesReceived = 0; + long inflightIncrements = 0; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, _, _) => + { + if (inst.Name == AhpTelemetryNames.MessagesReceived) Interlocked.Add(ref messagesReceived, measurement); + if (inst.Name == AhpTelemetryNames.RequestsInFlight && measurement > 0) Interlocked.Add(ref inflightIncrements, measurement); + }); + meterListener.Start(); + + await DriveOneInitializeAsync(); + + Assert.True(Interlocked.Read(ref messagesReceived) >= 1, "expected an ahp.client.messages.received measurement (the initialize response)"); + Assert.True(Interlocked.Read(ref inflightIncrements) >= 1, "expected ahp.client.requests.in_flight to record a +1 while the request was outstanding"); + } + + [Fact] + public async Task AttachSubscription_EmitsActiveSubscriptionGauge() + { + long ups = 0, downs = 0; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, _, _) => + { + if (inst.Name != AhpTelemetryNames.SubscriptionsActive) return; + if (measurement > 0) Interlocked.Add(ref ups, measurement); + else if (measurement < 0) Interlocked.Add(ref downs, -measurement); + }); + meterListener.Start(); + + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + + var sub = client.AttachSubscription("ahp-session:/s1"); + Assert.True(Interlocked.Read(ref ups) >= 1, "AttachSubscription should record a +1 on ahp.client.subscriptions.active"); + + sub.Close(); + Assert.True(Interlocked.Read(ref downs) >= 1, "Close should record a -1 on ahp.client.subscriptions.active"); + } + + // ══════════════════════════════════════════════════════════════════════ + // Tag-VALUE coverage — assert the real attribute values carried on each + // metric, not merely that a metric fired. Plus the previously-uncovered + // signals: dropped events (back-pressure), malformed frames (decode skip), + // and the MultiHostClient reconnect-supervisor outcome (added in this PR). + // ══════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Request_MessagesSent_CarriesRequestMessageKind() + { + // Capture the ahp.message.kind tag value on every messages.sent measurement + // and assert at least one carries the `request` value (the initialize RPC). + var kinds = new List(); + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, _, tags, _) => + { + if (inst.Name != AhpTelemetryNames.MessagesSent) return; + var kind = TagValue(tags, AhpTelemetryNames.AttrMessageKind); + if (kind is not null) lock (kinds) kinds.Add(kind); + }); + meterListener.Start(); + + await DriveOneInitializeAsync(); + + string[] snapshot; + lock (kinds) snapshot = kinds.ToArray(); + Assert.Contains(AhpTelemetryNames.MessageKindRequest, snapshot); + } + + [Fact] + public async Task Request_Duration_CarriesMethodAndOkOutcome() + { + // Assert the request.duration histogram carries BOTH the rpc.method value + // and the ahp.outcome=ok value for a successful initialize round-trip. + var matched = false; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, _, tags, _) => + { + if (inst.Name != AhpTelemetryNames.RequestDuration) return; + var method = TagValue(tags, AhpTelemetryNames.AttrRpcMethod); + var outcome = TagValue(tags, AhpTelemetryNames.AttrOutcome); + if (method == "initialize" && outcome == AhpTelemetryNames.OutcomeOk) Volatile.Write(ref matched, true); + }); + meterListener.Start(); + + await DriveOneInitializeAsync(); + + Assert.True(Volatile.Read(ref matched), + "expected an ahp.client.request.duration sample tagged rpc.method=initialize and ahp.outcome=ok"); + } + + [Fact] + public async Task DroppedEvents_UnderBackPressure_CountWithSubscriptionStreamTag() + { + // Drive a REAL back-pressure drop: a tiny subscription buffer (capacity 2) + // with no reader, fed more `action` notifications than it can hold, so the + // BoundedDropOldestChannel evicts the stalest events and fires the + // events.dropped counter tagged ahp.stream=subscription. + long drops = 0; + var streams = new List(); + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, tags, _) => + { + if (inst.Name != AhpTelemetryNames.EventsDropped) return; + Interlocked.Add(ref drops, measurement); + var stream = TagValue(tags, AhpTelemetryNames.AttrStream); + if (stream is not null) lock (streams) streams.Add(stream); + }); + meterListener.Start(); + + 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 = AhpClient.Connect( + clientSide, new ClientConfig { SubscriptionBufferCapacity = 2 }); + await client.InitializeAsync("test-client", cancellationToken: cts.Token); + await serverTask; + + const string uri = "ahp-session:/drops"; + // Hold the subscription but NEVER read its Events — a stalled consumer. + using var sub = client.AttachSubscription(uri); + + // Push more action notifications than the buffer (2) can hold. The reader + // never drains, so events past capacity are dropped-oldest. Several extra + // ensure ≥1 eviction deterministically. + for (long seq = 1; seq <= 8; seq++) + await serverSide.SendAsync(BuildActionNotification(uri, seq, $"e{seq}"), cts.Token); + + // The drop callback fires on the reader pump; spin briefly until observed. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3); + while (Interlocked.Read(ref drops) < 1 && DateTime.UtcNow < deadline) + await Task.Delay(20, cts.Token); + + Assert.True(Interlocked.Read(ref drops) >= 1, + "expected at least one ahp.client.events.dropped measurement under back-pressure"); + string[] streamSnapshot; + lock (streams) streamSnapshot = streams.ToArray(); + Assert.Contains(AhpTelemetryNames.StreamSubscription, streamSnapshot); + } + + [Fact] + public async Task MalformedFrame_IsSkipped_AndCounted() + { + // Feed a non-JSON text frame to the client reader. DecodeMessage throws, the + // reader skips the frame and increments ahp.client.frames.malformed, then + // keeps running (a subsequent valid frame still decodes — proven by the + // initialize response below resolving). + long malformed = 0; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, _, _) => + { + if (inst.Name == AhpTelemetryNames.FramesMalformed) Interlocked.Add(ref malformed, measurement); + }); + meterListener.Start(); + + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await using var client = AhpClient.Connect(clientSide); + + // 1) Inject a malformed frame from the server side — the reader skips it. + await serverSide.SendAsync(TransportMessage.FromText("{ this is not valid json"), cts.Token); + + // 2) Then a real initialize round-trip — proves the reader resynced and the + // client is still alive AFTER the malformed frame was skipped. + var serverTask = Task.Run(() => FakeServer.HandleOneInitialize(serverSide, cts.Token), cts.Token); + await client.InitializeAsync("test-client", cancellationToken: cts.Token); + await serverTask; + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (Interlocked.Read(ref malformed) < 1 && DateTime.UtcNow < deadline) + await Task.Delay(20, cts.Token); + + Assert.True(Interlocked.Read(ref malformed) >= 1, + "expected an ahp.client.frames.malformed measurement after a non-JSON frame"); + } + + [Fact] + public async Task MultiHostReconnect_EmitsReconnectsWithOkOutcome() + { + // Drive a REAL transport drop + supervised reconnect through MultiHostClient + // and assert its supervisor emits ahp.client.reconnects tagged ahp.outcome=ok + // on the successful reconnect. This covers the supervisor instrumentation + // added in this PR (the single-host AhpClient reconnect path is a separate + // emit site). + long okReconnects = 0; + using var meterListener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == AhpTelemetry.Name) l.EnableMeasurementEvents(inst); + }, + }; + meterListener.SetMeasurementEventCallback((inst, measurement, tags, _) => + { + if (inst.Name != AhpTelemetryNames.Reconnects) return; + if (TagValue(tags, AhpTelemetryNames.AttrOutcome) == AhpTelemetryNames.OutcomeOk) + Interlocked.Add(ref okReconnects, measurement); + }); + meterListener.Start(); + + 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 factory: first connection pushes seq=1 then drops; the + // supervisor reconnects and the second connection pushes seq=2. Identical + // shape to MultiHostClientTests.MultiHost_Reconnect_ReplaysActionsWithAdvancedSeq, + // but here we assert the reconnect METRIC rather than the replayed seq. + var attempt = 0; + HostTransportFactory factory = (hostId, ct) => + { + var n = Interlocked.Increment(ref attempt); + var (c, s) = MemTransport.CreatePair(); + if (n == 1) + { + _ = Task.Run(async () => + { + 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 s.SendAsync(BuildActionNotification(channel, 1, "e1"), ct).ConfigureAwait(false); + await Task.Delay(15, ct).ConfigureAwait(false); + } + } + } + catch { /* ignore */ } + finally { await s.CloseAsync().ConfigureAwait(false); } + }); + } + else + { + // Reconnected connection: answer `initialize` AND the supervisor's + // `reconnect` RPC (replaying an action at the advanced seq=2), and + // push live seq=2 actions after initialize. The FakeHost builder runs + // the canonical receive→decode→dispatch loop so the reconnect RPC + // actually settles — without it the supervisor's reconnect hangs. + _ = Task.Run(() => FakeHost.New() + .OnInitialize((req, side, c) => RespondInitializeAsync(side, req.Id, c)) + .AfterInitialize(async (side, c) => + { + while (!c.IsCancellationRequested) + { + await side.SendAsync(BuildActionNotification(channel, 2, "e2"), c).ConfigureAwait(false); + await Task.Delay(15, c).ConfigureAwait(false); + } + }) + .OnReconnect((req, side, c) => + { + var replay = new ReconnectReplayResult + { + Actions = new List + { + new ActionEnvelope + { + Channel = channel, + ServerSeq = 2, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "replay-2", + }), + }, + }, + Missing = new List(), + }; + return FakeHost.RespondResultAsync(side, req.Id, new ReconnectResult(replay), c); + }) + .RunAsync(s, ct)); + } + return Task.FromResult(c); + }; + + await m.AddHostAsync(new HostConfig + { + Id = new HostId("host-a"), + TransportFactory = (id, ct) => factory(id, ct), + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(20), + MaxBackoff = TimeSpan.FromMilliseconds(20), + BackoffMultiplier = 1.0, + ResetOnSuccess = true, + }, + }, cts.Token); + + // Drain until we see a post-reconnect (seq=2) event — proves the reconnect + // actually completed before we assert on the metric. + 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.True(Interlocked.Read(ref okReconnects) >= 1, + "expected the MultiHostClient supervisor to emit ahp.client.reconnects with ahp.outcome=ok on a successful reconnect"); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + + /// Reads a string-valued tag from a measurement's tag span, or null. + private static string? TagValue(ReadOnlySpan> tags, string key) + { + foreach (var tag in tags) + if (tag.Key == key) return tag.Value as string; + return null; + } + + /// Builds an `action` notification frame for . + private static TransportMessage BuildActionNotification(string uri, long serverSeq, string title) + { + var envelope = new ActionEnvelope + { + Channel = uri, + ServerSeq = serverSeq, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = title, + }), + }; + var notif = new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "action", + Params = Ser.SerializeToElement(envelope), + }, + }; + return Ser.EncodeMessage(notif); + } + + private static Task RespondInitializeAsync(MemTransport serverSide, ulong id, CancellationToken ct) => + FakeHost.RespondResultAsync( + serverSide, id, + new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, ct); + + private static async Task DriveOneInitializeAsync() + { + 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 = AhpClient.Connect(clientSide)) + { + await client.InitializeAsync("test-client", cancellationToken: cts.Token); + } + await serverTask; + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TransportLifetimeTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportLifetimeTests.cs new file mode 100644 index 00000000..3902f6e4 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportLifetimeTests.cs @@ -0,0 +1,68 @@ +// Proves the client disposes the transport it owns: ShutdownAsync must call +// ITransport.DisposeAsync (which releases unmanaged handles like the +// ClientWebSocket socket), not just CloseAsync. MemTransport holds no OS handles +// so it can't catch this — a recording double is used instead. +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class TransportLifetimeTests +{ + [Fact] + public async Task ShutdownAsync_DisposesTheOwnedTransport() + { + var transport = new RecordingTransport(); + var client = AhpClient.Connect(transport); + + await client.ShutdownAsync(TestContext.Current.CancellationToken); + + Assert.True(transport.Disposed, "the client owns the transport and must DisposeAsync it on shutdown"); + } + + [Fact] + public async Task ShutdownAsync_IsIdempotent() + { + var transport = new RecordingTransport(); + var client = AhpClient.Connect(transport); + + await client.ShutdownAsync(TestContext.Current.CancellationToken); + await client.ShutdownAsync(TestContext.Current.CancellationToken); // second call must be a safe no-op + + Assert.True(transport.Disposed); + } + + private sealed class RecordingTransport : ITransport + { + private readonly CancellationTokenSource _closed = new(); + public bool Disposed { get; private set; } + + public ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closed.Token); + try { await Task.Delay(Timeout.Infinite, linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) { /* fall through to the closed signal */ } + throw new AhpTransportException("closed"); + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _closed.Cancel(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Disposed = true; + _closed.Cancel(); + return ValueTask.CompletedTask; + } + } +} 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..6793fd57 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -0,0 +1,277 @@ +// Data-driven loader for the shared wire round-trip corpus under +// types/test-cases/round-trips/*.json. Each fixture's `input` is decoded with the +// REAL System.Text.Json serializer (SystemTextJsonAhpSerializer.Default) and the +// REAL generated wire types, then re-encoded with the same serializer; the result +// must structurally equal the single canonical form in acceptableOutputs[0]. +// +// The comparison is key-order-independent but value- and KEY-PRESENCE-sensitive: +// `null` is NOT normalized to absent (so an absent `origin` re-encoding as +// "origin": null is a failure, not a pass). +// +// The corpus is language-agnostic (see types/test-cases/round-trips/README.md); +// the same fixtures drive the Go / Swift / Rust / Kotlin / TypeScript clients. +// .NET is a runtime decoder (like Go/Swift/Rust/Kotlin): System.Text.Json drops +// unknown wire keys on decode, so .NET asserts acceptableOutputs[0] for BOTH +// group A and group B. The `group` field + `preservedOutput` only affect the +// TypeScript harness (TS has no runtime decoder and preserves unknown keys +// verbatim, so its expected output differs only for group B). +// +// The [Theory] CorpusFixture iterates EVERY fixture file, so adding a fixture is +// automatically exercised and a stray/garbled fixture fails loudly. +#nullable enable + +using System; +using System.Collections.Generic; +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 ─────────────────────────────────────────────── + + public static IEnumerable AllFixtures() => + EnumerateFixtureFiles().Select(path => new object[] { Path.GetFileName(path), path }); + + /// + /// Decodes each corpus fixture's input into the real generated type, + /// re-encodes it, and asserts structural equality with the single canonical + /// acceptableOutputs[0]. 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); + } + + /// + /// Protocol-version constants, previously exercised via corpus fixtures + /// 021–023 (removed because constant checks are not wire round-trips). Mirrors + /// the Go client's TestProtocolVersionConstants so coverage is not lost. + /// + [Fact] + public void ProtocolVersionConstants() + { + Assert.False( + string.IsNullOrWhiteSpace(ProtocolVersion.Current), + "ProtocolVersion.Current must be non-empty"); + Assert.NotEmpty(ProtocolVersion.Supported); + Assert.Equal(ProtocolVersion.Current, ProtocolVersion.Supported[0]); + } + + // ── Verifier ────────────────────────────────────────────────────────── + + private static void VerifyFixture(string path) + { + string fname = Path.GetFileName(path); + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement root = doc.RootElement; + + if (!root.TryGetProperty("type", out JsonElement typeEl) || typeEl.GetString() is not { } type) + { + throw new Xunit.Sdk.XunitException($"{fname}: missing `type`"); + } + + if (!root.TryGetProperty("input", out JsonElement input)) + { + throw new Xunit.Sdk.XunitException($"{fname}: missing `input`"); + } + + if (!root.TryGetProperty("acceptableOutputs", out JsonElement outputs) + || outputs.ValueKind != JsonValueKind.Array) + { + throw new Xunit.Sdk.XunitException($"{fname}: missing `acceptableOutputs` array"); + } + + // Single canonical form: acceptableOutputs MUST have exactly one entry. + // Multiple "acceptable" forms cement observed-but-wrong divergence between + // clients instead of pinning the single intended wire shape. + int outCount = outputs.GetArrayLength(); + if (outCount != 1) + { + throw new Xunit.Sdk.XunitException( + $"{fname}: acceptableOutputs must have exactly 1 entry (the single canonical re-encoded form); got {outCount}."); + } + + // `notApplicable` is a legacy opt-out (new fixtures use group:"B" + + // preservedOutput). Honor it so a fixture can still skip .NET if needed. + if (root.TryGetProperty("notApplicable", out JsonElement na) + && na.ValueKind == JsonValueKind.Array + && na.EnumerateArray().Any(c => c.GetString() is "dotnet" or "csharp")) + { + return; + } + + // Decode `input` as the real generated type, re-encode with the real serializer. + (_, string reencoded) = DecodeAndReencode(type, input.GetRawText()); + + using JsonDocument reDoc = JsonDocument.Parse(reencoded); + if (!JsonDeepEquals(outputs[0], reDoc.RootElement)) + { + throw new Xunit.Sdk.XunitException( + $"{fname}: re-encoded output does not match the canonical acceptableOutputs[0].\n" + + $" expected: {JsonSerializer.Serialize(outputs[0])}\n" + + $" actual: {reencoded}"); + } + } + + // ── Real decode dispatch ────────────────────────────────────────────── + + /// + /// Decodes into the real generated type named by + /// using the real serializer, then re-encodes it with + /// the same serializer. 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 "ChatInputQuestion": + 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)); + } + + // ── Structural JSON equality ────────────────────────────────────────── + + /// + /// Deep structural equality. Objects are compared key-order-independent but + /// key-presence-SENSITIVE (a null-valued member is NOT equal to an absent one, + /// so the origin omit-vs-null distinction is genuinely tested). Arrays compare + /// element-wise in order; numbers compare numerically (so 0 == 0.0 and 64-bit + /// values above Int32 stay exact). + /// + private static bool JsonDeepEquals(JsonElement a, JsonElement b) + { + if (a.ValueKind != b.ValueKind) + { + return false; + } + + switch (a.ValueKind) + { + case JsonValueKind.Object: + Dictionary aProps = + a.EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal); + Dictionary bProps = + b.EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal); + return aProps.Count == bProps.Count + && aProps.All(kv => bProps.TryGetValue(kv.Key, out JsonElement bv) && JsonDeepEquals(kv.Value, bv)); + + case JsonValueKind.Array: + if (a.GetArrayLength() != b.GetArrayLength()) + { + return false; + } + + JsonElement[] aItems = a.EnumerateArray().ToArray(); + JsonElement[] bItems = b.EnumerateArray().ToArray(); + for (int i = 0; i < aItems.Length; i++) + { + if (!JsonDeepEquals(aItems[i], bItems[i])) + { + return false; + } + } + + return true; + + case JsonValueKind.String: + return a.GetString() == b.GetString(); + + case JsonValueKind.Number: + return NumbersEqual(a, b); + + default: + // True / False / Null: ValueKind already matched, so they are equal. + return true; + } + } + + 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(); + } + + // ── Fixture file plumbing ───────────────────────────────────────────── + + 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/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..a61c04f9 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/independent-host-session-convergence.json @@ -0,0 +1,104 @@ +{ + "initial": { + "summary": { + "resource": "copilot:/interop-session", + "provider": "copilot", + "title": "Initial title", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "creating", + "activeClients": [], + "chats": [] + }, + "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 — 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 — renamed", + "status": 65, + "createdAt": 1000, + "modifiedAt": 2006, + "activity": "thinking" + }, + "lifecycle": "creating", + "activeClients": [], + "chats": [] + }, + "_source": "Captured from an independent AHP WebSocket host running the canonical TypeScript sessionReducer (independent implementation)." +} diff --git a/package.json b/package.json index dfd3842d..8d30cf90 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,17 @@ "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", + "verify:generated": "tsx scripts/verify-generated.ts", "docs:dev": "vitepress dev docs", "docs:build": "npm run generate && vitepress build docs", "docs:preview": "vitepress preview docs", "typecheck": "tsc --noEmit -p types/tsconfig.json", "lint": "eslint", - "test": "npm run typecheck && npm run lint && npm run verify:release-metadata && npm run verify:changelog && npx c8 --include types/reducers.ts --check-coverage --branches 100 tsx --test types/*.test.ts types/version/*.test.ts" + "test": "npm run typecheck && npm run lint && npm run verify:release-metadata && npm run verify:changelog && npm run verify:generated && npx c8 --include types/reducers.ts --check-coverage --branches 100 tsx --test types/*.test.ts types/version/*.test.ts clients/dotnet/codegen/telemetry/*.test.ts scripts/*.test.ts" }, "repository": { "type": "git", diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts new file mode 100644 index 00000000..e07ceebe --- /dev/null +++ b/scripts/generate-csharp.ts @@ -0,0 +1,2747 @@ +/** + * 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'; +import { readErrorCodes } from './read-error-codes.js'; +import { readTelemetry } from './read-telemetry.js'; + +const NAMESPACE = 'Microsoft.AgentHostProtocol'; + +function fileHeader(extraUsings: string[] = []): string { + // System / System.Collections.Generic / System.Text.Json{,.Serialization} + // are project-wide ``s (clients/dotnet/Directory.Build.props), so the + // generated files do not repeat them. Only file-specific usings are emitted. + const usingsBlock = extraUsings.length > 0 ? extraUsings.join('\n') + '\n\n' : ''; + return ( + '// \n' + + '// Generated from types/*.ts — do not edit.\n' + + '//\n' + + '// Regenerate with: npm run generate:dotnet\n' + + '// \n' + + '#nullable enable\n\n' + + usingsBlock + + `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); +} + +/** + * Replicates System.Text.Json's {@link JsonNamingPolicy.CamelCase}: lowercase + * the first character of the PascalCase C# identifier, leave the rest as-is. + * (STJ does NOT word-split — `JsonRpcVersion` → `jsonRpcVersion`, not + * `json_rpc_version`.) Used to decide whether a generated property still needs + * an explicit `[JsonPropertyName]`: the attribute is emitted only when the wire + * name differs from this policy-derived name. + */ +function csCamelCase(csName: string): string { + // Strip the `@` keyword-escape prefix (never appears on PascalCase props, but + // be defensive) before computing the policy name. + const bare = csName.startsWith('@') ? csName.slice(1) : csName; + if (bare.length === 0) return bare; + return bare[0].toLowerCase() + bare.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'; + // `JsonPrimitive` (string | number | boolean | null) is an arbitrary JSON + // scalar. C# has no union type; carry it as a raw `JsonElement` (the same + // representation this client already uses for `unknown` / `Default`, and the + // analog of go's `json.RawMessage`), so any primitive round-trips as-is. + if (tsType === 'JsonPrimitive') 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'; + + // SnapshotState is the discriminated union of all per-channel state types + // (RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | + // AnnotationsState, with `I`-prefixed interface variants in some positions). Match it + // structurally — any union whose members are all `*State` types — so a new state + // variant added upstream (e.g. ResourceWatchState) maps without a hand-edit here. + const unionMembers = tsType.split('|').map((m) => m.trim()); + if (unionMembers.length >= 2 && unionMembers.every((m) => /^I?[A-Z][A-Za-z0-9]*State$/.test(m))) { + 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 ──────────────────────────────────────────────────────────── + +/** + * Emits an XML `` doc comment, folding the ``/`` + * tags onto the prose lines instead of giving each its own line. A one-line + * summary becomes a single `/// Text` line; a multi-line + * summary opens the tag on the first prose line and closes it on the last. + * Either way the two standalone wrapper lines the verbose form used are shed, + * while the `` element (and the tooltip semantics) is preserved. + */ +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; + const escaped = docLines.map(escapeXmlDoc); + if (escaped.length === 1) { + lines.push(`${prefix}/// ${escaped[0]}`); + return; + } + escaped.forEach((line, idx) => { + const open = idx === 0 ? '' : ''; + const close = idx === escaped.length - 1 ? '' : ''; + lines.push(`${prefix}/// ${open}${line}${close}`.trimEnd()); + }); +} + +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; + /** + * When true, emit a mutable `class` with `{ get; set; }` accessors — for the + * STATE types the reducers mutate in place. When false (the default), emit a + * write-once `record` with `{ get; init; }` accessors: every action / command + * / notification payload, every envelope, and every write-once value object. + * Init-only records stay wire- and source-compatible (named-init still works) + * while making post-construction mutation a compile error. + */ + mutable?: boolean; +} + +/** + * True when a REQUIRED property is a reference type that must carry the C# + * `required` modifier rather than a fabricated default. This covers every + * non-nullable reference field the schema marks `required` — nested objects, + * strings, StringOrMarkdown wrappers, AND collections. `required` makes + * System.Text.Json reject a wire payload that omits the field (matching the + * schema's `required` array) instead of silently materializing `""` / `null!` + * / an empty wrapper. Value types and enums are excluded: a missing numeric / + * bool / enum decodes to its zero, matching Go's zero-value semantics, and the + * C# default-literal would be redundant. + */ +function csIsRequiredReference(csType: string, optional: boolean): boolean { + if (optional) return false; + if (csIsValueType(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 csIsRequiredReference(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 reference types (string, StringOrMarkdown, nested object, + // collection) use the `required` modifier — no fabricated default. A wire + // payload omitting the field is rejected on decode, matching the schema's + // `required` array. csRequiredModifier emits the keyword; no initializer. + return ''; +} + +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), + ); + + // Mutable STATE types are classes (reducers mutate in place); every other + // payload is a write-once record with init-only props. + const kind = opts.mutable ? 'class' : 'record'; + const accessor = opts.mutable ? 'get; set;' : 'get; init;'; + + lines.push(`public sealed ${kind} ${csName}`); + lines.push('{'); + emittedProps.forEach((p, idx) => { + if (idx > 0) lines.push(''); + if (p.doc) emitDocComment(' ', p.doc, lines); + // Emit [JsonPropertyName] only when the wire name diverges from the + // camelCase naming policy (jsonrpc / snake_case _meta + OAuth fields). + if (p.wireName !== csCamelCase(p.csName)) { + 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} { ${accessor} }${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', + // SessionInput*Enums have no TypeScript source — emitted from SESSION_INPUT_STRUCTS_CS below. + 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', + 'ChatInputQuestionKind', 'ChatInputResponseKind', + 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', + 'ToolCallConfirmationReason', 'ToolCallCancellationReason', + 'ConfirmationOptionKind', + 'ToolCallContributorKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'McpServerStatus', 'McpAuthRequiredReason', + 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', +]; + +// `mutable: true` marks the STATE types the reducers mutate in place — these +// stay `class` with `{ get; set; }`. Everything else is a write-once `record`. +const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: string; mutable?: boolean }[] = [ + { name: 'Icon' }, + { name: 'ProtectedResourceMetadata' }, + { name: 'RootState', mutable: true }, + { name: 'RootConfigState', mutable: true }, + { name: 'AgentInfo' }, + { name: 'SessionModelInfo' }, + { name: 'ModelSelection' }, + { name: 'AgentSelection' }, + { name: 'ConfigPropertySchema' }, + { name: 'ConfigSchema' }, + { name: 'PendingMessage' }, + { name: 'ChatSummary', mutable: true }, + { name: 'ChatState', mutable: true }, + { name: 'ChatInputOption' }, + { name: 'ChatInputTextQuestion' }, + { name: 'ChatInputNumberQuestion' }, + { name: 'ChatInputBooleanQuestion' }, + { name: 'ChatInputSingleSelectQuestion' }, + { name: 'ChatInputMultiSelectQuestion' }, + { name: 'ChatInputRequest', mutable: true }, + { name: 'ChatInputTextAnswerValue' }, + { name: 'ChatInputNumberAnswerValue' }, + { name: 'ChatInputBooleanAnswerValue' }, + { name: 'ChatInputSelectedAnswerValue' }, + { name: 'ChatInputSelectedManyAnswerValue' }, + { name: 'ChatInputAnswered' }, + { name: 'ChatInputSkipped' }, + { name: 'SessionState', mutable: true }, + { name: 'SessionActiveClient', mutable: true }, + { name: 'SessionSummary', mutable: true }, + { name: 'ChangesSummary' }, + { name: 'ProjectInfo' }, + { name: 'SessionConfigPropertySchema' }, + { name: 'SessionConfigSchema' }, + { name: 'SessionConfigState', mutable: true }, + { name: 'Turn', mutable: true }, + { name: 'ActiveTurn', mutable: true }, + { name: 'MessageOrigin' }, + { name: 'Message', mutable: true }, + // SessionInput* types have no TypeScript interfaces (they predate the generator's + // interface-based codegen) — emitted from SESSION_INPUT_STRUCTS_CS below. + { name: 'TextPosition' }, + { name: 'TextRange' }, + { name: 'TextSelection' }, + { name: 'SimpleMessageAttachment' }, + { name: 'MessageEmbeddedResourceAttachment' }, + { name: 'MessageResourceAttachment' }, + { name: 'MessageAnnotationsAttachment' }, + { name: 'MarkdownResponsePart', mutable: true }, + { name: 'ContentRef' }, + { name: 'ResourceReponsePart', csName: 'ResourceResponsePart' }, + { name: 'ToolCallResponsePart', mutable: true }, + { name: 'ReasoningResponsePart', mutable: true }, + { name: 'SystemNotificationResponsePart' }, + { name: 'ToolCallResult' }, + { name: 'ConfirmationOption' }, + { name: 'ToolCallStreamingState', mutable: true }, + { name: 'ToolCallPendingConfirmationState' }, + { name: 'ToolCallRunningState', mutable: true }, + { 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', mutable: true }, + { name: 'ClientPluginCustomization' }, + { name: 'DirectoryCustomization', mutable: true }, + { name: 'AgentCustomization' }, + { name: 'SkillCustomization' }, + { name: 'PromptCustomization' }, + { name: 'RuleCustomization' }, + { name: 'HookCustomization' }, + { name: 'McpServerCustomization', mutable: true }, + { 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', mutable: true }, + { name: 'TerminalUnclassifiedPart', mutable: true }, + { name: 'TerminalCommandPart', mutable: true }, + { name: 'UsageInfo' }, + { name: 'ErrorInfo' }, + { name: 'Snapshot' }, + { name: 'Changeset' }, + { name: 'ChangesetState', mutable: true }, + { name: 'ChangesetFile' }, + { name: 'ChangesetOperation', mutable: true }, + { 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 CHAT_ORIGIN_UNION_CS = `/// +/// ChatOrigin describes how a chat came into existence. +/// +[JsonConverter(typeof(ChatOriginConverter))] +public sealed class ChatOrigin : AhpUnion +{ + public ChatOrigin() { } + public ChatOrigin(object? value) : base(value) { } +} + +/// Chat was started by a user directly. +public sealed record ChatOriginUser +{ + public string Kind { get; init; } = "user"; +} + +/// Chat was forked from another chat at a specific turn. +public sealed record ChatOriginFork +{ + public string Kind { get; init; } = "fork"; + + public required string Chat { get; init; } + + public required string TurnId { get; init; } +} + +/// Chat was spawned by a tool call in another chat. +public sealed record ChatOriginTool +{ + public string Kind { get; init; } = "tool"; + + public required string Chat { get; init; } + + public required string ToolCallId { get; init; } +} + +/// System.Text.Json converter for the ChatOrigin union. +internal sealed class ChatOriginConverter : UnionConverter +{ + public ChatOriginConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["user"] = typeof(ChatOriginUser), + ["fork"] = typeof(ChatOriginFork), + ["tool"] = typeof(ChatOriginTool), + }, + allowUnknown: true) + { + } +}`; + +// SessionInput* types have no TypeScript interfaces in the protocol source — they +// predate the generator's interface-based codegen path. Emit them verbatim. +const SESSION_INPUT_STRUCTS_CS = `[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, +} + +/// A choice in a select-style question. +public sealed record SessionInputOption +{ + /// Stable option identifier; for MCP enum values this is the enum string + public required string Id { get; init; } + + /// Display label + public required string Label { get; init; } + + /// Optional secondary text + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Whether this option is the recommended/default choice + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Recommended { get; init; } +} + +/// Value captured for one answer. +public sealed record SessionInputTextAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public required string Value { get; init; } +} + +public sealed record SessionInputNumberAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public double Value { get; init; } +} + +public sealed record SessionInputBooleanAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public bool Value { get; init; } +} + +public sealed record SessionInputSelectedAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public required string Value { get; init; } + + /// Free-form text entered instead of selecting an option + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +public sealed record SessionInputSelectedManyAnswerValue +{ + public SessionInputAnswerValueKind Kind { get; init; } + + public required List Value { get; init; } + + /// Free-form text entered in addition to selected options + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +public sealed record SessionInputAnswered +{ + /// Answer state + public SessionInputAnswerState State { get; init; } + + /// Answer value + public required SessionInputAnswerValue Value { get; init; } +} + +public sealed record SessionInputSkipped +{ + /// Answer state + public SessionInputAnswerState State { get; init; } + + /// Free-form reason or value captured while skipping, if any + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? FreeformValues { get; init; } +} + +/// Text question within a session input request. +public sealed record SessionInputTextQuestion +{ + /// Stable question identifier used as the key in \`answers\` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Format hint for text questions, such as \`email\`, \`uri\`, \`date\`, or \`date-time\` + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Format { get; init; } + + /// Minimum string length + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; init; } + + /// Maximum string length + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; init; } + + /// Default text + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultValue { get; init; } +} + +/// Numeric question within a session input request. +public sealed record SessionInputNumberQuestion +{ + /// Stable question identifier used as the key in \`answers\` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Minimum value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Min { get; init; } + + /// Maximum value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Max { get; init; } + + /// Default numeric value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? DefaultValue { get; init; } +} + +/// Boolean question within a session input request. +public sealed record SessionInputBooleanQuestion +{ + /// Stable question identifier used as the key in \`answers\` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Default boolean value + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DefaultValue { get; init; } +} + +/// Single-select question within a session input request. +public sealed record SessionInputSingleSelectQuestion +{ + /// Stable question identifier used as the key in \`answers\` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Options the user may select from + public required List Options { get; init; } + + /// Whether the user may enter text instead of selecting an option + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; init; } +} + +/// Multi-select question within a session input request. +public sealed record SessionInputMultiSelectQuestion +{ + /// Stable question identifier used as the key in \`answers\` + public required string Id { get; init; } + + /// Short display title + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Prompt shown to the user + public required string Message { get; init; } + + /// Whether the user must answer this question to accept the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Required { get; init; } + + public SessionInputQuestionKind Kind { get; init; } + + /// Options the user may select from + public required List Options { get; init; } + + /// Whether the user may enter text in addition to selecting options + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFreeformInput { get; init; } + + /// Minimum selected item count + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Min { get; init; } + + /// Maximum selected item count + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Max { get; init; } +} + +/// 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 + public required string Id { get; set; } + + /// Display message for the request as a whole + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; set; } + + /// URL the user should review or open, for URL-style elicitations + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Url { get; set; } + + /// Ordered questions to ask the user + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Questions { get; set; } + + /// Current draft or submitted answers, keyed by question ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; set; } +}`; + +const CHAT_INPUT_QUESTION_UNION: UnionConfig = { + name: 'ChatInputQuestion', + discriminantField: 'kind', + doc: 'ChatInputQuestion is one question within a chat input request.', + variants: [ + { variantName: 'Text', innerType: 'ChatInputTextQuestion', wireValue: 'text' }, + { variantName: 'Number', innerType: 'ChatInputNumberQuestion', wireValue: 'number' }, + { variantName: 'Integer', innerType: 'ChatInputNumberQuestion', wireValue: 'integer' }, + { variantName: 'Boolean', innerType: 'ChatInputBooleanQuestion', wireValue: 'boolean' }, + { variantName: 'SingleSelect', innerType: 'ChatInputSingleSelectQuestion', wireValue: 'single-select' }, + { variantName: 'MultiSelect', innerType: 'ChatInputMultiSelectQuestion', wireValue: 'multi-select' }, + ], + unknown: true, +}; + +const CHAT_INPUT_ANSWER_VALUE_UNION: UnionConfig = { + name: 'ChatInputAnswerValue', + discriminantField: 'kind', + doc: 'ChatInputAnswerValue is the value captured for one chat input answer.', + variants: [ + { variantName: 'Text', innerType: 'ChatInputTextAnswerValue', wireValue: 'text' }, + { variantName: 'Number', innerType: 'ChatInputNumberAnswerValue', wireValue: 'number' }, + { variantName: 'Boolean', innerType: 'ChatInputBooleanAnswerValue', wireValue: 'boolean' }, + { variantName: 'Selected', innerType: 'ChatInputSelectedAnswerValue', wireValue: 'selected' }, + { variantName: 'SelectedMany', innerType: 'ChatInputSelectedManyAnswerValue', wireValue: 'selected-many' }, + ], + unknown: true, +}; + +const CHAT_INPUT_ANSWER_UNION: UnionConfig = { + name: 'ChatInputAnswer', + discriminantField: 'state', + doc: 'ChatInputAnswer is a draft, submitted, or skipped answer for one chat input question.', + variants: [ + { variantName: 'Draft', innerType: 'ChatInputAnswered', wireValue: 'draft' }, + { variantName: 'Submitted', innerType: 'ChatInputAnswered', wireValue: 'submitted' }, + { variantName: 'Skipped', innerType: 'ChatInputSkipped', wireValue: 'skipped' }, + ], + 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, +/// chat, terminal, changeset, resource-watch, or annotations state. Read +/// probes for distinctive fields in an order where no probe shadows another +/// (chat → session → terminal → changeset → resource-watch → annotations → 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; } + + /// Chat state variant, when populated. + public ChatState? Chat { get; set; } + + /// Terminal state variant, when populated. + public TerminalState? Terminal { get; set; } + + /// Changeset state variant, when populated. + public ChangesetState? Changeset { get; set; } + + /// Resource-watch state variant, when populated. + public ResourceWatchState? ResourceWatch { get; set; } + + /// Annotations state variant, when populated. + public AnnotationsState? Annotations { 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("turns", out _)) + { + result.Chat = root.Deserialize(options); + } + else 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 if (root.TryGetProperty("root", out _) && root.TryGetProperty("recursive", out _)) + { + result.ResourceWatch = root.Deserialize(options); + } + else if (root.TryGetProperty("annotations", out _)) + { + result.Annotations = root.Deserialize(options); + } + else + { + result.Root = root.Deserialize(options); + } + return result; + } + + public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) + { + if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, options); return; } + 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.ResourceWatch is not null) { JsonSerializer.Serialize(writer, value.ResourceWatch, options); return; } + if (value.Annotations is not null) { JsonSerializer.Serialize(writer, value.Annotations, 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, + mutable: entry.mutable, + }), + ); + lines.push(''); + } catch (e) { + lines.push(`// TODO: could not generate ${entry.name}: ${e}`); + lines.push(''); + } + } + + lines.push('// ─── Hand-written State Types ─────────────────────────────────────────\n'); + lines.push(SESSION_INPUT_STRUCTS_CS); + 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, + CHAT_INPUT_QUESTION_UNION, CHAT_INPUT_ANSWER_VALUE_UNION, CHAT_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(CHAT_ORIGIN_UNION_CS); + 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' }, + // Session turn/tool-call/error/usage/reasoning actions have no TypeScript interfaces in + // channels-session/actions.ts — emit hand-written C# for them (SESSION_ACTION_TYPES_CS below). + { type: 'session/turnStarted', variantName: 'SessionTurnStarted', tsInterface: '_hand_written_session_action_' }, + { type: 'session/delta', variantName: 'SessionDelta', tsInterface: '_hand_written_session_action_' }, + { type: 'session/responsePart', variantName: 'SessionResponsePart', tsInterface: '_hand_written_session_action_' }, + { type: 'session/toolCallStart', variantName: 'SessionToolCallStart', tsInterface: '_hand_written_session_action_' }, + { type: 'session/toolCallDelta', variantName: 'SessionToolCallDelta', tsInterface: '_hand_written_session_action_' }, + { type: 'session/toolCallReady', variantName: 'SessionToolCallReady', tsInterface: '_hand_written_session_action_' }, + { type: 'session/toolCallConfirmed', variantName: 'SessionToolCallConfirmed', tsInterface: '_merged_' }, + { type: 'session/toolCallComplete', variantName: 'SessionToolCallComplete', tsInterface: '_hand_written_session_action_' }, + { type: 'session/toolCallResultConfirmed', variantName: 'SessionToolCallResultConfirmed', tsInterface: '_hand_written_session_action_' }, + { type: 'session/turnComplete', variantName: 'SessionTurnComplete', tsInterface: '_hand_written_session_action_' }, + { type: 'session/turnCancelled', variantName: 'SessionTurnCancelled', tsInterface: '_hand_written_session_action_' }, + { type: 'session/error', variantName: 'SessionError', tsInterface: '_hand_written_session_action_' }, + { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, + { type: 'session/usage', variantName: 'SessionUsage', tsInterface: '_hand_written_session_action_' }, + { type: 'session/reasoning', variantName: 'SessionReasoning', tsInterface: '_hand_written_session_action_' }, + { 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/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, + { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, + { type: 'session/pendingMessageSet', variantName: 'SessionPendingMessageSet', tsInterface: '_hand_written_session_action_' }, + { type: 'session/pendingMessageRemoved', variantName: 'SessionPendingMessageRemoved', tsInterface: '_hand_written_session_action_' }, + { type: 'session/queuedMessagesReordered', variantName: 'SessionQueuedMessagesReordered', tsInterface: '_hand_written_session_action_' }, + { type: 'session/inputRequested', variantName: 'SessionInputRequested', tsInterface: '_hand_written_session_action_' }, + { type: 'session/inputAnswerChanged', variantName: 'SessionInputAnswerChanged', tsInterface: '_hand_written_session_action_' }, + { type: 'session/inputCompleted', variantName: 'SessionInputCompleted', tsInterface: '_hand_written_session_action_' }, + { 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' }, + // SessionTruncatedAction and SessionToolCallContentChangedAction have no TypeScript + // interfaces in the protocol source — emit hand-written C# for them below. + { type: 'session/truncated', variantName: 'SessionTruncated', tsInterface: '_hand_written_session_truncated_' }, + { type: 'session/configChanged', variantName: 'SessionConfigChanged', tsInterface: 'SessionConfigChangedAction' }, + { type: 'session/metaChanged', variantName: 'SessionMetaChanged', tsInterface: 'SessionMetaChangedAction' }, + { type: 'session/toolCallContentChanged', variantName: 'SessionToolCallContentChanged', tsInterface: '_hand_written_session_toolcallcontent_' }, + { type: 'session/chatAdded', variantName: 'SessionChatAdded', tsInterface: 'SessionChatAddedAction' }, + { type: 'session/chatRemoved', variantName: 'SessionChatRemoved', tsInterface: 'SessionChatRemovedAction' }, + { type: 'session/chatUpdated', variantName: 'SessionChatUpdated', tsInterface: 'SessionChatUpdatedAction' }, + { type: 'session/defaultChatChanged', variantName: 'SessionDefaultChatChanged', tsInterface: 'SessionDefaultChatChangedAction' }, + { type: 'chat/turnStarted', variantName: 'ChatTurnStarted', tsInterface: 'ChatTurnStartedAction' }, + { type: 'chat/delta', variantName: 'ChatDelta', tsInterface: 'ChatDeltaAction' }, + { type: 'chat/responsePart', variantName: 'ChatResponsePart', tsInterface: 'ChatResponsePartAction' }, + { type: 'chat/toolCallStart', variantName: 'ChatToolCallStart', tsInterface: 'ChatToolCallStartAction' }, + { type: 'chat/toolCallDelta', variantName: 'ChatToolCallDelta', tsInterface: 'ChatToolCallDeltaAction' }, + { type: 'chat/toolCallReady', variantName: 'ChatToolCallReady', tsInterface: 'ChatToolCallReadyAction' }, + { type: 'chat/toolCallConfirmed', variantName: 'ChatToolCallConfirmed', tsInterface: '_merged_chat_' }, + { type: 'chat/toolCallComplete', variantName: 'ChatToolCallComplete', tsInterface: 'ChatToolCallCompleteAction' }, + { type: 'chat/toolCallResultConfirmed', variantName: 'ChatToolCallResultConfirmed', tsInterface: 'ChatToolCallResultConfirmedAction' }, + { type: 'chat/toolCallContentChanged', variantName: 'ChatToolCallContentChanged', tsInterface: 'ChatToolCallContentChangedAction' }, + { type: 'chat/turnComplete', variantName: 'ChatTurnComplete', tsInterface: 'ChatTurnCompleteAction' }, + { type: 'chat/turnCancelled', variantName: 'ChatTurnCancelled', tsInterface: 'ChatTurnCancelledAction' }, + { type: 'chat/error', variantName: 'ChatError', tsInterface: 'ChatErrorAction' }, + { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, + { type: 'chat/reasoning', variantName: 'ChatReasoning', tsInterface: 'ChatReasoningAction' }, + { type: 'chat/truncated', variantName: 'ChatTruncated', tsInterface: 'ChatTruncatedAction' }, + { type: 'chat/pendingMessageSet', variantName: 'ChatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, + { type: 'chat/pendingMessageRemoved', variantName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' }, + { type: 'chat/queuedMessagesReordered', variantName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' }, + { type: 'chat/inputRequested', variantName: 'ChatInputRequested', tsInterface: 'ChatInputRequestedAction' }, + { type: 'chat/inputAnswerChanged', variantName: 'ChatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' }, + { type: 'chat/inputCompleted', variantName: 'ChatInputCompleted', tsInterface: 'ChatInputCompletedAction' }, + { type: 'changeset/statusChanged', variantName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, + { type: 'changeset/fileSet', variantName: 'ChangesetFileSet', tsInterface: 'ChangesetFileSetAction' }, + { type: 'changeset/fileRemoved', variantName: 'ChangesetFileRemoved', tsInterface: 'ChangesetFileRemovedAction' }, + { type: 'changeset/contentChanged', variantName: 'ChangesetContentChanged', tsInterface: 'ChangesetContentChangedAction' }, + { 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' }, + { type: 'annotations/updated', variantName: 'AnnotationsUpdated', tsInterface: 'AnnotationsUpdatedAction' }, +]; + +function generateMergedToolCallConfirmedClass(): string { + return `/// +/// SessionToolCallConfirmedAction — the client approves or denies a +/// pending tool call (merged approved + denied variants on the wire). +/// +public sealed record SessionToolCallConfirmedAction +{ + public ActionType Type { get; init; } + + public required string TurnId { get; init; } + + public required string ToolCallId { get; init; } + + // _meta is snake_case; camelCase(Meta) would be "meta". + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public bool Approved { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallCancellationReason? Reason { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditedToolInput { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SelectedOptionId { get; init; } +}`; +} + +function generateMergedChatToolCallConfirmedClass(): string { + return `/// +/// ChatToolCallConfirmedAction — the client approves or denies a +/// pending chat tool call (merged approved + denied variants on the wire). +/// +public sealed record ChatToolCallConfirmedAction +{ + public ActionType Type { get; init; } + + public required string TurnId { get; init; } + + public required string ToolCallId { get; init; } + + // _meta is snake_case; camelCase(Meta) would be "meta". + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public bool Approved { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallCancellationReason? Reason { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditedToolInput { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Message? UserSuggestion { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ReasonMessage { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SelectedOptionId { get; init; } +}`; +} + +function generateSessionTruncatedActionClass(): string { + return `/// 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 record SessionTruncatedAction +{ + public ActionType Type { get; init; } + + /// Keep turns up to and including this turn. Omit to clear all turns. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TurnId { get; init; } +}`; +} + +function generateSessionToolCallContentChangedActionClass(): string { + return `/// 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 record SessionToolCallContentChangedAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + /// 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; init; } + + public ActionType Type { get; init; } + + /// The current partial content for the running tool call + public required List Content { get; init; } +}`; +} + +// Hand-written C# for the 19 session action types that have no TypeScript interfaces in +// channels-session/actions.ts. Their chat-channel counterparts DO have TypeScript interfaces +// (ChatTurnStartedAction etc.), but the session-channel versions were always hand-maintained. +// Keep in ACTION_VARIANTS order so the generated union matches. +const SESSION_ACTION_TYPES_CS = `public sealed record SessionTurnStartedAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// The new message + public required Message Message { get; init; } + + /// If this turn was auto-started from a queued message, the ID of that message + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QueuedMessageId { get; init; } +} + +/// 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 record SessionDeltaAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Identifier of the response part to append to + public required string PartId { get; init; } + + /// Text chunk + public required string Content { get; init; } +} + +/// Structured content appended to the response. +public sealed record SessionResponsePartAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Response part (markdown or content ref) + public required ResponsePart Part { get; init; } +} + +/// A tool call begins — parameters are streaming from the LM. +public sealed record SessionToolCallStartAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Internal tool name (for debugging/logging) + public required string ToolName { get; init; } + + /// Human-readable tool name + public required string DisplayName { get; init; } + + /// Reference to the contributor of the tool being called. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallContributor? Contributor { get; init; } +} + +/// Streaming partial parameters for a tool call. +public sealed record SessionToolCallDeltaAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Partial parameter content to append + public required string Content { get; init; } + + /// Updated progress message + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? InvocationMessage { get; init; } +} + +/// Tool call parameters are complete, or a running tool requires re-confirmation. +public sealed record SessionToolCallReadyAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Message describing what the tool will do or what confirmation is needed + public required StringOrMarkdown InvocationMessage { get; init; } + + /// Raw tool input + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolInput { get; init; } + + /// Short title for the confirmation prompt + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StringOrMarkdown? ConfirmationTitle { get; init; } + + /// File edits that this tool call will perform, for preview before confirmation + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Edits { get; init; } + + /// Whether the agent host allows the client to edit the tool's input parameters before confirming + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Editable { get; init; } + + /// If set, the tool was auto-confirmed and transitions directly to \`running\` + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolCallConfirmationReason? Confirmed { get; init; } + + /// Options the server offers for this confirmation. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; init; } +} + +/// Tool execution finished. +public sealed record SessionToolCallCompleteAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Execution result + public required ToolCallResult Result { get; init; } + + /// If true, the result requires client approval before finalizing + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RequiresResultConfirmation { get; init; } +} + +/// Client approves or denies a tool's result. +public sealed record SessionToolCallResultConfirmedAction +{ + /// Turn identifier + public required string TurnId { get; init; } + + /// Tool call identifier + public required string ToolCallId { get; init; } + + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + public ActionType Type { get; init; } + + /// Whether the result was approved + public bool Approved { get; init; } +} + +/// Turn finished — the assistant is idle. +public sealed record SessionTurnCompleteAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } +} + +/// Turn was aborted; server stops processing. +public sealed record SessionTurnCancelledAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } +} + +/// Error during turn processing. +public sealed record SessionErrorAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Error details + public required ErrorInfo Error { get; init; } +} + +/// Token usage report for a turn. +public sealed record SessionUsageAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Token usage data + public required UsageInfo Usage { get; init; } +} + +/// Reasoning/thinking text from the model, appended to a specific reasoning response part. +public sealed record SessionReasoningAction +{ + public ActionType Type { get; init; } + + /// Turn identifier + public required string TurnId { get; init; } + + /// Identifier of the reasoning response part to append to + public required string PartId { get; init; } + + /// Reasoning text chunk + public required string Content { get; init; } +} + +/// A pending message was set (upsert semantics: creates or replaces). +public sealed record SessionPendingMessageSetAction +{ + public ActionType Type { get; init; } + + /// Whether this is a steering or queued message + public PendingMessageKind Kind { get; init; } + + /// Unique identifier for this pending message + public required string Id { get; init; } + + /// The message content + public required Message Message { get; init; } +} + +/// A pending message was removed (steering or queued). +public sealed record SessionPendingMessageRemovedAction +{ + public ActionType Type { get; init; } + + /// Whether this is a steering or queued message + public PendingMessageKind Kind { get; init; } + + /// Identifier of the pending message to remove + public required string Id { get; init; } +} + +/// Reorder the queued messages. +public sealed record SessionQueuedMessagesReorderedAction +{ + public ActionType Type { get; init; } + + /// Queued message IDs in the desired order + public required List Order { get; init; } +} + +/// A session requested input from the user. +public sealed record SessionInputRequestedAction +{ + public ActionType Type { get; init; } + + /// Input request to create or replace + public required SessionInputRequest Request { get; init; } +} + +/// A client updated, submitted, skipped, or removed a single in-progress answer. +public sealed record SessionInputAnswerChangedAction +{ + public ActionType Type { get; init; } + + /// Input request identifier + public required string RequestId { get; init; } + + /// Question identifier within the input request + public required string QuestionId { get; init; } + + /// Updated answer, or undefined to clear an answer draft + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionInputAnswer? Answer { get; init; } +} + +/// A client accepted, declined, or cancelled a session input request. +public sealed record SessionInputCompletedAction +{ + public ActionType Type { get; init; } + + /// Input request identifier + public required string RequestId { get; init; } + + /// Completion outcome + public SessionInputResponseKind Response { get; init; } + + /// Optional final answer replacement, keyed by question ID + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Answers { get; init; } +}`; + +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 record ActionEnvelope +{ + public required string Channel { get; init; } + + public required StateAction Action { get; init; } + + public long ServerSeq { get; init; } + + /// The origin of the action. Required-nullable in the wire schema + /// (ActionOrigin | undefined): omitted when absent (server-originated), + /// never serialized as null. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ActionOrigin? Origin { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RejectionReason { get; init; } +}`; +} + +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' + : v.tsInterface === '_merged_chat_' ? 'ChatToolCallConfirmedAction' + : v.tsInterface === '_hand_written_session_truncated_' ? 'SessionTruncatedAction' + : v.tsInterface === '_hand_written_session_toolcallcontent_' ? 'SessionToolCallContentChangedAction' + : v.tsInterface === '_hand_written_session_action_' ? `${v.variantName}Action` + : 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(''); + + const priorPartials = new Set(requiredPartialStructs); + + lines.push('// ─── Action Payloads ─────────────────────────────────────────────────\n'); + let handWrittenSessionActionsEmitted = false; + for (const v of ACTION_VARIANTS) { + if (v.tsInterface === '_merged_') { + lines.push(generateMergedToolCallConfirmedClass()); + lines.push(''); + continue; + } + if (v.tsInterface === '_merged_chat_') { + lines.push(generateMergedChatToolCallConfirmedClass()); + lines.push(''); + continue; + } + if (v.tsInterface === '_hand_written_session_truncated_') { + lines.push(generateSessionTruncatedActionClass()); + lines.push(''); + continue; + } + if (v.tsInterface === '_hand_written_session_toolcallcontent_') { + lines.push(generateSessionToolCallContentChangedActionClass()); + lines.push(''); + continue; + } + if (v.tsInterface === '_hand_written_session_action_') { + // All 19 hand-written session action types are emitted as a single block. + // Only emit on the first encounter; skip subsequent occurrences. + if (!handWrittenSessionActionsEmitted) { + lines.push(SESSION_ACTION_TYPES_CS); + lines.push(''); + handWrittenSessionActionsEmitted = true; + } + 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(''); + } + } + + // Emit any Partial types that were discovered processing actions (e.g. SessionChatUpdatedAction + // uses Partial), so they are available in this file rather than waiting for the + // notifications file (which comes later and only emits its own newly-discovered partials). + const newPartials = [...requiredPartialStructs].filter((n) => !priorPartials.has(n)); + if (newPartials.length > 0) { + lines.push('// ─── Partial Summaries (action-discovered) ───────────────────────────\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(''); + } + } + } + + 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: 'ChatForkSource' }, { name: 'CreateChatParams' }, + { name: 'DisposeChatParams' }, + { 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 record ChangesetOperationResourceTarget +{ + public string Kind { get; init; } = "resource"; + + public required string Resource { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; init; } +} + +/// Targets a range within a resource. +public sealed record ChangesetOperationRangeTarget +{ + public string Kind { get; init; } = "range"; + + public required string Resource { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Side { get; init; } + + public required TextRange Range { get; init; } +} + +/// 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(project: Project): string { + // Derive both code lists from types/common/errors.ts (the single source of + // truth) rather than hand-maintaining a copy here — a new code added to + // errors.ts then appears automatically instead of silently going missing + // (the cause of `Conflict` being absent before). + const { jsonRpc, ahp } = readErrorCodes(project); + const xml = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>'); + const emitCodes = ( + summary: string, + className: string, + codes: readonly { name: string; code: number; doc: string }[], + ): string => { + const lines = [`/// ${summary}`, `public static class ${className}`, '{']; + for (const { name, code, doc } of codes) { + if (doc) lines.push(` /// ${xml(doc)}`); + lines.push(` public const int ${name} = ${code};`); + } + lines.push('}'); + return lines.join('\n'); + }; + return `${fileHeader()} +${emitCodes('Standard JSON-RPC 2.0 error codes.', 'JsonRpcErrorCodes', jsonRpc)} + +${emitCodes('AHP application-specific error codes (above the JSON-RPC reserved range).', 'AhpErrorCodes', ahp)} + +/// Detail payload of an AuthRequired (-32007) error. +public sealed record AuthRequiredErrorData +{ + public required List Resources { get; init; } +} + +/// Detail payload of a PermissionDenied (-32009) error. +public sealed record PermissionDeniedErrorData +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceRequestParams? Request { get; init; } +} + +/// Detail payload of an UnsupportedProtocolVersion (-32005) error. +public sealed record UnsupportedProtocolVersionErrorData +{ + public required List SupportedVersions { get; init; } +} +`; +} + +// ─── 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 +{ + // jsonrpc diverges from camelCase(JsonRpcVersion); keep the explicit name. + [JsonPropertyName("jsonrpc")] + public string JsonRpcVersion { get; set; } = JsonRpc.Version; + + public ulong Id { get; set; } + + public string Method { get; set; } = ""; + + [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; + + public ulong Id { get; set; } + + 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; + + public ulong Id { get; set; } + + public JsonRpcErrorObject Error { get; set; } = new(); +} + +/// The standard JSON-RPC 2.0 error object. +public sealed class JsonRpcErrorObject +{ + public int Code { get; set; } + + public string Message { get; set; } = ""; + + [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; + + public string Method { get; set; } = ""; + + [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.startsWith('_')) + .map((v) => v.tsInterface), + ]); + + const knownSpecial = new Set([ + 'URI', 'JsonPrimitive', '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', + 'ChatOrigin', 'ChatInputQuestion', 'ChatInputAnswerValue', 'ChatInputAnswer', + 'ChatToolCallConfirmedAction', 'ChatToolCallApprovedAction', 'ChatToolCallDeniedAction', + 'ChatForkSource', 'ChatAction', + ]); + + 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'), + ); + } +} + +// ─── Telemetry Names File Generator ────────────────────────────────────────── + +function generateTelemetryFile(project: Project): string { + const t = readTelemetry(project); + const body: string[] = []; + // XML-escape a description for a /// doc comment. + const xml = (s: string): string => s.replace(/&/g, '&').replace(//g, '>'); + const doc = (text: string): void => { + if (text) body.push(` /// ${xml(text)}`); + }; + body.push('/// '); + body.push('/// Telemetry NAMES this client emits about its own operation, codegen\'d from the'); + body.push('/// client-private registry clients/dotnet/codegen/telemetry/registry.ts so the'); + body.push('/// span / metric / attribute names stay in one place. Names only; the'); + body.push('/// ActivitySource / Meter wiring is hand-written in Telemetry.cs. The registry'); + body.push('/// is structured for promotion to a shared cross-client contract if AHP ever specs'); + body.push('/// one (see issue #239).'); + body.push('/// '); + body.push('public static class AhpTelemetryNames'); + body.push('{'); + doc(t.source.doc); + body.push(` public const string Source = ${JSON.stringify(t.source.value)};`); + body.push(''); + body.push(' // ── Span names ──'); + for (const span of t.spans) { + doc(span.doc); + body.push(` public const string ${span.id}Span = ${JSON.stringify(span.value)};`); + } + body.push(''); + body.push(' // ── Metric names ──'); + for (const metric of t.metrics) { + doc(metric.doc); + body.push(` public const string ${metric.id} = ${JSON.stringify(metric.value)};`); + } + body.push(''); + body.push(' // ── Metric units ──'); + for (const metric of t.metrics) { + doc(`Unit for the \`${metric.value}\` metric.`); + body.push(` public const string ${metric.id}Unit = ${JSON.stringify(metric.unit)};`); + } + body.push(''); + body.push(' // ── Metric descriptions ──'); + body.push(' // Single source for the human-readable instrument description, used both as'); + body.push(' // the doc-comment summary above each metric name AND as the runtime'); + body.push(' // `description:` passed to Meter.CreateX in Telemetry.cs — so the two cannot drift.'); + for (const metric of t.metrics) { + doc(`Description for the \`${metric.value}\` metric.`); + body.push(` public const string ${metric.id}Description = ${JSON.stringify(metric.doc)};`); + } + body.push(''); + body.push(' // ── Attribute keys ──'); + for (const attr of t.attributes) { + doc(attr.doc); + body.push(` public const string Attr${attr.id} = ${JSON.stringify(attr.value)};`); + } + body.push(''); + body.push(' // ── Attribute values ──'); + for (const grp of t.values) { + for (const member of grp.members) { + doc(member.doc); + body.push(` public const string ${grp.group}${member.id} = ${JSON.stringify(member.value)};`); + } + } + body.push('}'); + return `${fileHeader()}\n${body.join('\n')}\n`; +} + +// ─── Main Entry Point ──────────────────────────────────────────────────────── + +export function generateCSharpPackage(project: Project, outputDir: string): void { + collectEnumNames(project); + checkExhaustiveness(project); + + // The telemetry-name registry is a .NET-client-private codegen source (it lives + // under the client tree, not in `types/`, because AHP has not specced a + // cross-client self-instrumentation contract — see #239). The shared ts-morph + // project is built from `types/tsconfig.json`, so add the client-private + // registry here; `readTelemetry` then locates it by its `/telemetry/registry.ts` + // path suffix. If AHP ever specs a shared contract, move this file back to + // `types/telemetry/registry.ts` and drop this line. + project.addSourceFileAtPath(path.join(outputDir, 'codegen', 'telemetry', 'registry.ts')); + + 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(project)); + fs.writeFileSync(path.join(srcDir, 'Messages.generated.cs'), generateMessagesFile()); + fs.writeFileSync(path.join(srcDir, 'Version.generated.cs'), generateVersionFile(project)); + fs.writeFileSync(path.join(srcDir, 'Telemetry.generated.cs'), generateTelemetryFile(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 8f8091f5..b8d71b46 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 allowMissingFormatter = args.includes('--allow-missing-formatter'); const generateAll = @@ -49,6 +52,7 @@ const generateAll = !kotlinOnly && !typescriptOnly && !goOnly && + !dotnetOnly && !metadataOnly; // Load the TypeScript project @@ -111,6 +115,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/read-error-codes.ts b/scripts/read-error-codes.ts new file mode 100644 index 00000000..d0fe1eb0 --- /dev/null +++ b/scripts/read-error-codes.ts @@ -0,0 +1,96 @@ +/** + * Shared helper for the protocol type generators: read the + * `JsonRpcErrorCodes` and `AhpErrorCodes` constant objects from + * `types/common/errors.ts` using ts-morph AST traversal, so a generator + * emits the full, current code list rather than a hand-maintained copy + * that silently goes stale when a new code is added (the cause of the + * `Conflict` code being missing from the .NET client). + * + * Each code carries its leading `/** ... *\/` comment text so generators can + * emit a doc comment on the constant they produce. Throws loudly if either + * constant is missing or malformed, so a refactor of `errors.ts` fails the + * generator rather than silently producing a stale list. + */ + +import { Node, Project, SyntaxKind } from 'ts-morph'; + +/** One error code parsed from `types/common/errors.ts`. */ +export interface ErrorCode { + /** Member name, e.g. `Conflict`. */ + readonly name: string; + /** Numeric code, e.g. `-32011`. */ + readonly code: number; + /** Leading-comment description (empty string if the member has none). */ + readonly doc: string; +} + +/** Parsed error-code lists from `types/common/errors.ts`. */ +export interface ErrorCodes { + /** Standard JSON-RPC 2.0 reserved codes (`JsonRpcErrorCodes`). */ + readonly jsonRpc: readonly ErrorCode[]; + /** AHP application-specific codes (`AhpErrorCodes`). */ + readonly ahp: readonly ErrorCode[]; +} + +/** Collapse a `/** ... *\/` (or `//`) comment block into a single line of text. */ +function stripComment(raw: string): string { + return raw + .replace(/^\/\*\*?/, '') + .replace(/\*\/\s*$/, '') + .replace(/^\/\/+/, '') + .split('\n') + .map((line) => line.replace(/^\s*\*?\s?/, '').trim()) + .filter((line) => line.length > 0) + .join(' ') + .trim(); +} + +function readObject(project: Project, constName: string): ErrorCode[] { + const sf = project + .getSourceFiles() + .find((f) => f.getFilePath().endsWith('/common/errors.ts')); + if (!sf) { + throw new Error('readErrorCodes: could not locate types/common/errors.ts in project'); + } + const decl = sf.getVariableDeclaration(constName); + if (!decl) { + throw new Error(`readErrorCodes: ${constName} not found in types/common/errors.ts`); + } + let init: Node | undefined = decl.getInitializer(); + const asExpr = init?.asKind(SyntaxKind.AsExpression); + if (asExpr) init = asExpr.getExpression(); + const obj = init?.asKind(SyntaxKind.ObjectLiteralExpression); + if (!obj) { + throw new Error(`readErrorCodes: ${constName} is not an \`... as const\` object literal`); + } + + const out: ErrorCode[] = []; + for (const prop of obj.getProperties()) { + const pa = prop.asKind(SyntaxKind.PropertyAssignment); + if (!pa) continue; + const name = pa.getName(); + const code = Number(pa.getInitializerOrThrow().getText()); + if (!Number.isFinite(code)) { + throw new Error(`readErrorCodes: ${constName}.${name} is not a numeric literal`); + } + const ranges = pa.getLeadingCommentRanges(); + const doc = ranges.length ? stripComment(ranges[ranges.length - 1].getText()) : ''; + out.push({ name, code, doc }); + } + if (out.length === 0) { + throw new Error(`readErrorCodes: ${constName} has no members`); + } + return out; +} + +/** + * Read both error-code constant objects from `types/common/errors.ts`. + * Callers building a partial ts-morph project must include + * `types/common/errors.ts` among the source files. + */ +export function readErrorCodes(project: Project): ErrorCodes { + return { + jsonRpc: readObject(project, 'JsonRpcErrorCodes'), + ahp: readObject(project, 'AhpErrorCodes'), + }; +} diff --git a/scripts/read-telemetry.test.ts b/scripts/read-telemetry.test.ts new file mode 100644 index 00000000..56bebea6 --- /dev/null +++ b/scripts/read-telemetry.test.ts @@ -0,0 +1,175 @@ +/** + * Tests for `read-telemetry.ts` — the shared ts-morph reader the per-language + * generators use to extract the telemetry-name enums + their descriptions. + * + * These run over a small IN-MEMORY fixture registry (built with + * `project.createSourceFile`) rather than the real `types/telemetry/registry.ts` + * so the assertions pin the reader's behavior independently of the live + * contract: unit resolution from the computed `[TelemetryMetric.X]` keys, + * `getJsDocs()` member-description extraction (including the empty-doc case), + * and the module-doc-vs-const-doc heuristic that picks the LAST leading JSDoc + * for `TELEMETRY_SOURCE`. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Project } from 'ts-morph'; + +import { readTelemetry } from './read-telemetry.js'; + +/** + * Build an in-memory project whose only source file is a telemetry registry at + * a path that ends in `/telemetry/registry.ts` (which is how `readTelemetry` + * locates it). The fixture mirrors the real registry's SHAPE — a module-level + * JSDoc, a `TELEMETRY_SOURCE` const with its own doc, a `TELEMETRY_METRIC_UNITS` + * record keyed by computed `[TelemetryMetric.X]` members, and the span / metric + * / attribute / value enums — but with trimmed, fixture-only contents. + */ +function fixtureProject(source: string): Project { + const project = new Project({ useInMemoryFileSystem: true }); + project.createSourceFile('types/telemetry/registry.ts', source); + return project; +} + +const FIXTURE = ` +/** + * Module-level doc that must NOT be mistaken for the TELEMETRY_SOURCE doc. + * @module telemetry/registry + */ + +/** The instrumentation-scope name. */ +export const TELEMETRY_SOURCE = 'Fixture.Scope'; + +export enum TelemetrySpan { + /** A request span. */ + Request = 'ahp.request', +} + +export enum TelemetryMetric { + /** Messages sent. */ + MessagesSent = 'ahp.client.messages.sent', + /** Request duration. */ + RequestDuration = 'ahp.client.request.duration', +} + +export const TELEMETRY_METRIC_UNITS: Record = { + [TelemetryMetric.MessagesSent]: '{message}', + [TelemetryMetric.RequestDuration]: 'ms', +}; + +export enum TelemetryAttribute { + /** rpc.method tag. */ + RpcMethod = 'rpc.method', + // intentionally undocumented to exercise the empty-doc branch + Stream = 'ahp.stream', +} + +export enum TelemetryRpcSystem { + /** jsonrpc. */ + Jsonrpc = 'jsonrpc', +} + +export enum TelemetryOutcome { + /** ok. */ + Ok = 'ok', +} + +export enum TelemetryMessageKind { + /** request. */ + Request = 'request', +} + +export enum TelemetryStream { + /** subscription stream. */ + Subscription = 'subscription', + /** multi-host event stream. */ + HostEvent = 'host-event', +} +`; + +test('reads source value and the CONST doc (not the module doc)', () => { + const data = readTelemetry(fixtureProject(FIXTURE)); + assert.equal(data.source.value, 'Fixture.Scope'); + // The module-level JSDoc precedes the const's own JSDoc; the reader picks the + // LAST leading doc, which is the const's own. + assert.equal(data.source.doc, 'The instrumentation-scope name.'); + assert.doesNotMatch(data.source.doc, /Module-level doc/); +}); + +test('extracts span name + value + member JSDoc', () => { + const data = readTelemetry(fixtureProject(FIXTURE)); + assert.deepEqual(data.spans, [ + { id: 'Request', value: 'ahp.request', doc: 'A request span.' }, + ]); +}); + +test('resolves each metric to its unit via the computed [TelemetryMetric.X] keys', () => { + const data = readTelemetry(fixtureProject(FIXTURE)); + const byId = Object.fromEntries(data.metrics.map((m) => [m.id, m])); + assert.equal(byId.MessagesSent.unit, '{message}'); + assert.equal(byId.MessagesSent.value, 'ahp.client.messages.sent'); + assert.equal(byId.RequestDuration.unit, 'ms'); +}); + +test('an undocumented member yields an empty doc string', () => { + const data = readTelemetry(fixtureProject(FIXTURE)); + const stream = data.attributes.find((a) => a.id === 'Stream'); + assert.ok(stream, 'expected a Stream attribute'); + assert.equal(stream.doc, ''); +}); + +test('groups attribute VALUE enums (minus the Telemetry prefix), including hyphenated values', () => { + const data = readTelemetry(fixtureProject(FIXTURE)); + const groupNames = data.values.map((g) => g.group); + assert.deepEqual(groupNames, ['RpcSystem', 'Outcome', 'MessageKind', 'Stream']); + + const streamGroup = data.values.find((g) => g.group === 'Stream'); + assert.ok(streamGroup, 'expected a Stream value group'); + const hostEvent = streamGroup.members.find((m) => m.id === 'HostEvent'); + assert.ok(hostEvent, 'expected the HostEvent value'); + assert.equal(hostEvent.value, 'host-event'); +}); + +test('throws loudly when a required enum is missing', () => { + const broken = ` + /** doc */ + export const TELEMETRY_SOURCE = 'X'; + export const TELEMETRY_METRIC_UNITS: Record = {}; + export enum TelemetrySpan { Request = 'ahp.request' } + `; + assert.throws( + () => readTelemetry(fixtureProject(broken)), + /enum TelemetryMetric not found/, + ); +}); + +test('throws when the registry source file is not in the project', () => { + const project = new Project({ useInMemoryFileSystem: true }); + project.createSourceFile('types/unrelated.ts', 'export const x = 1;'); + assert.throws(() => readTelemetry(project), /could not locate a .*\/telemetry\/registry\.ts source file/); +}); + +test('throws when an enum member is not string-valued', () => { + const numeric = ` + /** doc */ + export const TELEMETRY_SOURCE = 'X'; + export enum TelemetrySpan { + /** numeric */ + Request = 1, + } + export enum TelemetryMetric { MessagesSent = 'ahp.client.messages.sent' } + export const TELEMETRY_METRIC_UNITS: Record = { + [TelemetryMetric.MessagesSent]: '{message}', + }; + export enum TelemetryAttribute { RpcMethod = 'rpc.method' } + export enum TelemetryRpcSystem { Jsonrpc = 'jsonrpc' } + export enum TelemetryOutcome { Ok = 'ok' } + export enum TelemetryMessageKind { Request = 'request' } + export enum TelemetryStream { Subscription = 'subscription' } + `; + assert.throws( + () => readTelemetry(fixtureProject(numeric)), + /TelemetrySpan\.Request is not a string-valued enum member/, + ); +}); diff --git a/scripts/read-telemetry.ts b/scripts/read-telemetry.ts new file mode 100644 index 00000000..70f43c29 --- /dev/null +++ b/scripts/read-telemetry.ts @@ -0,0 +1,142 @@ +/** + * Shared helper for the type generators: read the telemetry name enums from a + * telemetry registry module (any source file whose path ends in + * `/telemetry/registry.ts` — today the .NET-client-private + * `clients/dotnet/codegen/telemetry/registry.ts`, or `types/telemetry/registry.ts` + * if AHP ever specs a shared contract) via ts-morph, returning each name's + * identifier, wire value, and `getJsDocs()`-extracted description. + * + * This is the SAME extraction mechanism the generators use for every protocol + * enum (`enumDecl.getMembers()` + `member.getJsDocs()`) — telemetry doesn't get + * a second comment mechanism. The generators consume the returned data and emit + * a flat per-language constant holder (telemetry names are used as raw strings, + * not as language enums, so the output shape stays a flat holder). + * + * Throws loudly if the registry's expected enums/const are missing or + * malformed, so a refactor of `registry.ts` fails the generator rather than + * silently producing stale output. + */ + +import { EnumDeclaration, Project, SyntaxKind } from 'ts-morph'; + +/** A single telemetry name: its identifier, wire value, and description. */ +export interface TelemetryName { + /** Enum member identifier, e.g. `MessagesSent`. */ + readonly id: string; + /** Wire value, e.g. `ahp.client.messages.sent`. */ + readonly value: string; + /** `getJsDocs()`-extracted description (empty if the member has none). */ + readonly doc: string; +} + +/** A telemetry metric: a name plus its OTel unit annotation. */ +export interface TelemetryMetricName extends TelemetryName { + /** OTel unit, e.g. `{message}` or `ms`. */ + readonly unit: string; +} + +/** A group of attribute values, e.g. the `outcome` values `{ok, error, ...}`. */ +export interface TelemetryValueGroup { + /** Group identifier (enum name minus the `Telemetry` prefix), e.g. `Outcome`. */ + readonly group: string; + /** The values in the group. */ + readonly members: readonly TelemetryName[]; +} + +/** Everything the generators need to emit a telemetry-names holder. */ +export interface TelemetryData { + readonly source: { readonly value: string; readonly doc: string }; + readonly spans: readonly TelemetryName[]; + readonly metrics: readonly TelemetryMetricName[]; + readonly attributes: readonly TelemetryName[]; + readonly values: readonly TelemetryValueGroup[]; +} + +/** Enums that are attribute-VALUE groups (everything except spans/metrics/attributes). */ +const VALUE_ENUMS = [ + 'TelemetryRpcSystem', + 'TelemetryOutcome', + 'TelemetryMessageKind', + 'TelemetryStream', +] as const; + +function members(enumDecl: EnumDeclaration): TelemetryName[] { + return enumDecl.getMembers().map((m) => { + const value = m.getValue(); + if (typeof value !== 'string') { + throw new Error( + `readTelemetry: ${enumDecl.getName()}.${m.getName()} is not a string-valued enum member`, + ); + } + return { + id: m.getName(), + value, + doc: m.getJsDocs()[0]?.getDescription().trim() ?? '', + }; + }); +} + +/** Read the telemetry registry. The project must include a source file whose path ends in `/telemetry/registry.ts`. */ +export function readTelemetry(project: Project): TelemetryData { + const sf = project + .getSourceFiles() + .find((f) => f.getFilePath().endsWith('/telemetry/registry.ts')); + if (!sf) { + throw new Error('readTelemetry: could not locate a **/telemetry/registry.ts source file in project'); + } + const getEnum = (name: string): EnumDeclaration => { + const decl = sf.getEnum(name); + if (!decl) throw new Error(`readTelemetry: enum ${name} not found in registry.ts`); + return decl; + }; + + // TELEMETRY_SOURCE — a top-level const; getJsDocs() reads the statement-level doc. + const sourceDecl = sf.getVariableDeclaration('TELEMETRY_SOURCE'); + if (!sourceDecl) throw new Error('readTelemetry: TELEMETRY_SOURCE not found'); + const sourceValue = sourceDecl.getInitializerOrThrow().asKind(SyntaxKind.StringLiteral)?.getLiteralValue(); + if (sourceValue === undefined) throw new Error('readTelemetry: TELEMETRY_SOURCE is not a string literal'); + // The first declaration in the file inherits the module-level JSDoc as its + // first leading doc; the const's OWN doc is the last one immediately above it. + const sourceJsDocs = sourceDecl.getVariableStatementOrThrow().getJsDocs(); + const sourceDoc = sourceJsDocs.at(-1)?.getDescription().trim() ?? ''; + + // TELEMETRY_METRIC_UNITS — Record; map metric VALUE -> unit. + const unitsDecl = sf.getVariableDeclarationOrThrow('TELEMETRY_METRIC_UNITS'); + const unitsObj = unitsDecl.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression); + const unitByMetricValue = new Map(); + for (const prop of unitsObj.getProperties()) { + const pa = prop.asKind(SyntaxKind.PropertyAssignment); + if (!pa) continue; + // key is a computed [TelemetryMetric.X] -> resolve to the enum member's value + const nameNode = pa.getNameNode(); + const computed = nameNode.asKind(SyntaxKind.ComputedPropertyName); + const access = computed?.getExpression().asKind(SyntaxKind.PropertyAccessExpression); + const memberId = access?.getName(); + const metricValue = memberId + ? getEnum('TelemetryMetric').getMemberOrThrow(memberId).getValue() + : undefined; + const unit = pa.getInitializerOrThrow().asKind(SyntaxKind.StringLiteral)?.getLiteralValue(); + if (typeof metricValue === 'string' && unit !== undefined) { + unitByMetricValue.set(metricValue, unit); + } + } + + const metrics: TelemetryMetricName[] = members(getEnum('TelemetryMetric')).map((m) => { + const unit = unitByMetricValue.get(m.value); + if (unit === undefined) throw new Error(`readTelemetry: no unit for metric ${m.value}`); + return { ...m, unit }; + }); + + const values: TelemetryValueGroup[] = VALUE_ENUMS.map((enumName) => ({ + group: enumName.replace(/^Telemetry/, ''), + members: members(getEnum(enumName)), + })); + + return { + source: { value: sourceValue, doc: sourceDoc }, + spans: members(getEnum('TelemetrySpan')), + metrics, + attributes: members(getEnum('TelemetryAttribute')), + values, + }; +} 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/scripts/verify-generated.ts b/scripts/verify-generated.ts new file mode 100644 index 00000000..33abcb7c --- /dev/null +++ b/scripts/verify-generated.ts @@ -0,0 +1,132 @@ +/** + * Verify generated output freshness — mechanically enforces that the committed + * telemetry-name holder is "identical by construction" to what the generator + * emits RIGHT NOW from the telemetry registry + * (`clients/dotnet/codegen/telemetry/registry.ts`). + * + * Why a runtime gate on top of the per-language `git diff` drift checks in + * `ci.yml`: the git-diff checks only fire inside the CI job that runs the + * matching `generate:` step, and they depend on a clean git tree. This + * script regenerates the holders and compares the committed bytes against the + * freshly-generated bytes WITHOUT depending on git, so the same "did you forget + * to regenerate?" guard runs from a plain `npm test` on any contributor's + * machine. It pairs with `verify-changelog.ts` / `verify-release-metadata.ts` + * as a third "no stale committed artifact" gate. + * + * Mechanism: the generators write in place. For each holder this script checks, + * it snapshots the committed bytes, runs the owning `generate:` entry + * point, reads the regenerated bytes, then ALWAYS restores the snapshot (so the + * gate is non-destructive even when it finds drift — the working tree is left + * exactly as it was found). Regeneration is deterministic and the rest of the + * tree is already in sync (CI's drift checks guarantee that), so regenerating + * to compare a holder leaves every other file byte-identical. + * + * Run via `npm run verify:generated` (also wired into `npm test`). + */ + +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +/** A committed generated holder + the generate script that produces it. */ +interface GeneratedHolder { + /** Human label for failure output. */ + readonly label: string; + /** Path (relative to ROOT) of the committed generated file. */ + readonly file: string; + /** `package.json` script (`npm run `) that regenerates it. */ + readonly generate: string; +} + +/** + * The committed telemetry-name holder(s). The .NET holder is generated from the + * client-private registry `clients/dotnet/codegen/telemetry/registry.ts`. + * + * This PR (the first-party .NET client) ships only the .NET telemetry holder. + * AHP has not specced a shared cross-client self-instrumentation names contract + * (issue #239, on the backlog); if it ever does, the registry moves to + * `types/telemetry/registry.ts`, the other-language holders join this list, and + * the freshness gate covers them too. + */ +const HOLDERS: readonly GeneratedHolder[] = [ + { + label: 'dotnet', + file: 'clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Telemetry.generated.cs', + generate: 'generate:dotnet', + }, +]; + +function runGenerate(script: string): void { + // `--allow-missing-formatter` keeps the Rust/Go generators from failing when + // rustfmt/gofmt is absent; the committed holders are already formatted, so a + // missing formatter must not turn this freshness gate into a false negative. + execFileSync('npm', ['run', script, '--', '--allow-missing-formatter'], { + cwd: ROOT, + stdio: 'pipe', + }); +} + +function main(): void { + const drifted: { holder: GeneratedHolder }[] = []; + + // Group holders by the generate script so we run each generator once. + const byScript = new Map(); + for (const holder of HOLDERS) { + const list = byScript.get(holder.generate) ?? []; + list.push(holder); + byScript.set(holder.generate, list); + } + + for (const [script, holders] of byScript) { + // Snapshot the committed bytes for every holder this generator owns. + const snapshots = holders.map((holder) => { + const abs = path.join(ROOT, holder.file); + if (!fs.existsSync(abs)) { + throw new Error(`verify-generated: committed holder is missing: ${holder.file}`); + } + return { holder, abs, committed: fs.readFileSync(abs) }; + }); + + try { + runGenerate(script); + for (const { holder, abs, committed } of snapshots) { + const regenerated = fs.readFileSync(abs); + if (!committed.equals(regenerated)) { + drifted.push({ holder }); + } + } + } finally { + // Always restore the committed bytes — the gate never mutates the tree, + // whether it passed or found drift. + for (const { abs, committed } of snapshots) { + fs.writeFileSync(abs, committed); + } + } + } + + if (drifted.length > 0) { + console.error('❌ Generated-output freshness check failed:'); + for (const { holder } of drifted) { + console.error( + ` [${holder.label}] ${holder.file} is stale — it does not match the ` + + `output of 'npm run ${holder.generate}'.`, + ); + } + console.error( + " hint: run 'npm run generate' and commit the regenerated holders. " + + 'The telemetry holders are generated from types/telemetry/registry.ts; ' + + 'edit the registry, not the holders.', + ); + process.exit(1); + } + + console.log( + `✅ Generated-output freshness check passed for: ${HOLDERS.map((h) => h.label).join(', ')}`, + ); +} + +main(); diff --git a/tsconfig.json b/tsconfig.json index 77e74527..a598e9bc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "forceConsistentCasingInFileNames": true, "types": ["node"] }, - "include": ["scripts/**/*.ts"] + "include": ["scripts/**/*.ts", "clients/dotnet/codegen/**/*.ts"] }