From 1b01b276fab3f91e953150263f2df10f18a1491a Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Wed, 26 Aug 2026 21:08:05 -0400 Subject: [PATCH] test: add live .NET TypeScript session conformance --- .github/workflows/ci.yml | 4 + clients/dotnet/AGENTS.md | 19 +- clients/dotnet/README.md | 10 + .../RealSocketTypeScriptConformanceTests.cs | 172 ++++++++++++++++++ .../interop/session-host.ts | 157 ++++++++++++++++ .../interop/tsconfig.json | 12 ++ ...dotnet-typescript-session-conformance.json | 5 + package-lock.json | 36 +++- package.json | 4 +- 9 files changed, 406 insertions(+), 13 deletions(-) create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/RealSocketTypeScriptConformanceTests.cs create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/interop/tsconfig.json create mode 100644 docs/.changes/20260827-dotnet-typescript-session-conformance.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72a7c5f40..40a0a206d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,10 @@ jobs: working-directory: . run: npm ci + - name: Typecheck .NET interoperability host + working-directory: . + run: npx tsc -p clients/dotnet/tests/AgentHostProtocol.Tests/interop/tsconfig.json + # 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. diff --git a/clients/dotnet/AGENTS.md b/clients/dotnet/AGENTS.md index c46234bb7..5d797f967 100644 --- a/clients/dotnet/AGENTS.md +++ b/clients/dotnet/AGENTS.md @@ -68,17 +68,14 @@ against `net8.0`: 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.) + replays a session trace captured from an independent host, while + `RealSocketTypeScriptConformanceTests` launches the repository-local + TypeScript conformance host over a real WebSocket and proves current-version + negotiation, snapshot seeding from `InitializeResult`, and streamed action + convergence through the handwritten C# reducers. The host imports the + canonical TypeScript `sessionReducer` directly and uses the development-only + `ws` package for server framing; it does not use a published package or an + external service. Cross-language parity is verified by the shared fixture corpora the suite replays — the 189 reducer fixtures (`types/test-cases/reducers/*.json`) and the diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index a4bcaa9b6..b5d3839b8 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -164,6 +164,16 @@ local NuGet packages, and runs it as a native executable with initialization, reconnect replay, subscriptions and reducers, custom generic requests and notifications, and typed and raw inbound request handling. +The .NET test suite also owns a hermetic cross-implementation lane. It starts a +repository-local TypeScript WebSocket host that imports the canonical +`sessionReducer`, connects through the real .NET `WebSocketTransport`, and +verifies current protocol negotiation, `InitializeResult` snapshot seeding, +and structurally equivalent state after the streamed actions pass through the +handwritten C# reducers. Run `npm ci` at the repository root before +`dotnet test` so the checked-in TypeScript host can use the repository's `tsx` +toolchain and the development-only `ws` server; no published AHP package or +separately running service is involved. + ## Releasing 1. Bump [`VERSION`](VERSION). diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/RealSocketTypeScriptConformanceTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/RealSocketTypeScriptConformanceTests.cs new file mode 100644 index 000000000..2ce591253 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/RealSocketTypeScriptConformanceTests.cs @@ -0,0 +1,172 @@ +#nullable enable + +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +/// +/// Runs the real .NET WebSocket transport and client against a repository-local +/// TypeScript host that owns the authoritative state through sessionReducer. +/// This keeps negotiation, initialize snapshot seeding, streaming, and reducer +/// convergence inside one hermetic CI lane. +/// +public sealed class RealSocketTypeScriptConformanceTests +{ + private const string SessionUri = "ahp-session:/dotnet-typescript-conformance"; + + [Fact] + public async Task CurrentProtocol_InitializeSnapshotAndStreamConvergeWithTypeScriptReducer() + { + using var testTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + await using var host = await TypeScriptHost.StartAsync(testTimeout.Token); + var transport = await WebSocketTransport.ConnectAsync(host.Uri, cancellationToken: testTimeout.Token); + await using var client = AhpClient.Connect( + transport, + new ClientConfig { DefaultRequestTimeout = TimeSpan.FromSeconds(5) }); + + var initialized = await client.InitializeAsync( + "dotnet-typescript-conformance", + new[] { ProtocolVersion.Current }, + new[] { SessionUri }, + testTimeout.Token); + + Assert.Equal(ProtocolVersion.Current, initialized.ProtocolVersion); + Assert.Equal(0, initialized.ServerSeq); + var snapshot = Assert.Single(initialized.Snapshots); + Assert.Equal(SessionUri, snapshot.Resource); + Assert.Equal(0, snapshot.FromSeq); + var state = Assert.IsType(snapshot.State.Session); + Assert.Equal("Seeded by initialize", state.Title); + Assert.Equal(SessionLifecycle.Creating, state.Lifecycle); + + using var subscription = client.AttachSubscription(SessionUri); + var run = await client.RequestAsync, JsonElement>( + "interop/run", + new Dictionary { ["channel"] = SessionUri }, + testTimeout.Token); + + var actionCount = run.GetProperty("actionCount").GetInt32(); + Assert.Equal(4, actionCount); + for (var expectedSeq = 1; expectedSeq <= actionCount; expectedSeq += 1) + { + var received = await subscription.Events.ReadAsync(testTimeout.Token); + var actionEvent = Assert.IsType(received); + Assert.Equal(SessionUri, actionEvent.Envelope.Channel); + Assert.Equal(expectedSeq, actionEvent.Envelope.ServerSeq); + Assert.Equal(ReduceOutcome.Applied, Reducers.ApplyToSession(state, actionEvent.Envelope.Action)); + } + + Assert.Equal(actionCount, run.GetProperty("serverSeq").GetInt32()); + var actual = JsonSerializer.SerializeToElement(state, AhpJson.Options); + Assert.Equal(JsonCanon.Of(run.GetProperty("finalState")), JsonCanon.Of(actual)); + } + + private sealed class TypeScriptHost : IAsyncDisposable + { + private readonly Process _process; + private readonly Task _stderr; + + private TypeScriptHost(Process process, Task stderr, int port) + { + _process = process; + _stderr = stderr; + Uri = new Uri($"ws://127.0.0.1:{port}"); + } + + public Uri Uri { get; } + + public static async Task StartAsync(CancellationToken cancellationToken) + { + var repoRoot = FindRepoRoot(); + var script = Path.Combine( + repoRoot, + "clients", + "dotnet", + "tests", + "AgentHostProtocol.Tests", + "interop", + "session-host.ts"); + var start = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("NODE") ?? "node", + WorkingDirectory = repoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add("--import"); + start.ArgumentList.Add("tsx"); + start.ArgumentList.Add(script); + + var process = Process.Start(start) + ?? throw new InvalidOperationException("Failed to start the repository-local TypeScript host."); + var stderr = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + using var readinessTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + readinessTimeout.CancelAfter(TimeSpan.FromSeconds(3)); + var readyLine = await process.StandardOutput.ReadLineAsync(readinessTimeout.Token); + if (readyLine is null) + { + await process.WaitForExitAsync(readinessTimeout.Token); + throw new InvalidOperationException( + $"TypeScript host exited before readiness: {await stderr}"); + } + using var ready = JsonDocument.Parse(readyLine); + Assert.Equal("ready", ready.RootElement.GetProperty("type").GetString()); + return new TypeScriptHost(process, stderr, ready.RootElement.GetProperty("port").GetInt32()); + } + catch + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + process.Dispose(); + throw; + } + } + + public async ValueTask DisposeAsync() + { + try + { + var stoppedByTest = !_process.HasExited; + if (stoppedByTest) + { + _process.Kill(entireProcessTree: true); + using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _process.WaitForExitAsync(shutdownTimeout.Token); + } + if (!stoppedByTest && _process.ExitCode != 0) + { + throw new InvalidOperationException( + $"TypeScript host exited with code {_process.ExitCode}: {await _stderr}"); + } + } + finally + { + _process.Dispose(); + } + } + + private static string FindRepoRoot() + { + var starts = new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }; + foreach (var start in starts) + { + for (var directory = new DirectoryInfo(start); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "package.json")) + && File.Exists(Path.Combine(directory.FullName, "types", "reducers.ts"))) + { + return directory.FullName; + } + } + } + throw new DirectoryNotFoundException("Could not locate the Agent Host Protocol repository root."); + } + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts new file mode 100644 index 000000000..0ac294c62 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts @@ -0,0 +1,157 @@ +import { WebSocketServer, type WebSocket } from 'ws'; + +import { + ActionType, + PROTOCOL_VERSION, + SessionLifecycle, + SessionStatus, + sessionReducer, + type SessionAction, + type SessionState, +} from '../../../../../types/index.js'; + +const sessionUri = 'ahp-session:/dotnet-typescript-conformance'; +const initialState: SessionState = { + provider: 'typescript-conformance-host', + title: 'Seeded by initialize', + status: SessionStatus.Idle, + lifecycle: SessionLifecycle.Creating, + activeClients: [], + chats: [], +}; + +const actions: readonly SessionAction[] = [ + { type: ActionType.SessionTitleChanged, title: 'Reduced by both implementations' }, + { type: ActionType.SessionIsReadChanged, isRead: true }, + { type: ActionType.SessionActivityChanged, activity: 'streaming' }, + { type: ActionType.SessionIsArchivedChanged, isArchived: true }, +]; + +interface JsonRpcRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params?: unknown; +} + +function send(socket: WebSocket, value: unknown): void { + socket.send(JSON.stringify(value)); +} + +function success(socket: WebSocket, id: number, result: unknown): void { + send(socket, { jsonrpc: '2.0', id, result }); +} + +function failure(socket: WebSocket, id: number, code: number, message: string): void { + send(socket, { jsonrpc: '2.0', id, error: { code, message } }); +} + +function isRequest(value: unknown): value is JsonRpcRequest { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as Partial; + return candidate.jsonrpc === '2.0' + && typeof candidate.id === 'number' + && typeof candidate.method === 'string'; +} + +function handleRequest(socket: WebSocket, request: JsonRpcRequest, initialized: { value: boolean }): void { + if (request.method === 'initialize') { + if (initialized.value) { + failure(socket, request.id, -32600, 'initialize may only be sent once'); + return; + } + const params = request.params as { + channel?: unknown; + clientId?: unknown; + protocolVersions?: unknown; + initialSubscriptions?: unknown; + } | undefined; + const offered = params?.protocolVersions; + const subscriptions = params?.initialSubscriptions; + if (params?.channel !== 'ahp-root://' + || typeof params.clientId !== 'string' + || !Array.isArray(offered) + || !offered.every(version => typeof version === 'string') + || !offered.includes(PROTOCOL_VERSION) + || !Array.isArray(subscriptions) + || subscriptions.length !== 1 + || subscriptions[0] !== sessionUri) { + failure(socket, request.id, -32602, 'invalid current-protocol initialize request'); + return; + } + initialized.value = true; + success(socket, request.id, { + protocolVersion: PROTOCOL_VERSION, + serverSeq: 0, + serverInfo: { name: 'repository-local-typescript-conformance-host', version: PROTOCOL_VERSION }, + snapshots: [{ resource: sessionUri, state: initialState, fromSeq: 0 }], + }); + return; + } + + if (request.method === 'interop/run') { + if (!initialized.value) { + failure(socket, request.id, -32002, 'initialize must be the first request'); + return; + } + const params = request.params as { channel?: unknown } | undefined; + if (params?.channel !== sessionUri) { + failure(socket, request.id, -32602, 'interop/run must target the seeded session'); + return; + } + let state = structuredClone(initialState); + let serverSeq = 0; + for (const action of actions) { + state = sessionReducer(state, action); + serverSeq += 1; + send(socket, { + jsonrpc: '2.0', + method: 'action', + params: { channel: sessionUri, action, serverSeq }, + }); + } + success(socket, request.id, { actionCount: actions.length, finalState: state, serverSeq }); + return; + } + + failure(socket, request.id, -32601, `unknown method: ${request.method}`); +} + +const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); +server.on('connection', socket => { + const initialized = { value: false }; + socket.on('message', payload => { + let decoded: unknown; + try { + decoded = JSON.parse(payload.toString()); + } catch { + socket.close(1007, 'invalid JSON payload'); + return; + } + if (isRequest(decoded)) { + handleRequest(socket, decoded, initialized); + } + }); +}); +server.on('listening', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a TCP address'); + } + console.log(JSON.stringify({ type: 'ready', port: address.port })); +}); +server.on('error', error => { + console.error(error); + process.exitCode = 1; +}); + +function shutdown(): void { + for (const socket of server.clients) { + socket.close(1001, 'server shutting down'); + } + server.close(() => process.exit(0)); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/interop/tsconfig.json b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/tsconfig.json new file mode 100644 index 000000000..4363757ca --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node", "ws"] + }, + "files": ["session-host.ts"] +} diff --git a/docs/.changes/20260827-dotnet-typescript-session-conformance.json b/docs/.changes/20260827-dotnet-typescript-session-conformance.json new file mode 100644 index 000000000..c83382b85 --- /dev/null +++ b/docs/.changes/20260827-dotnet-typescript-session-conformance.json @@ -0,0 +1,5 @@ +{ + "type": "added", + "message": "A hermetic real-WebSocket conformance lane now verifies .NET session negotiation, initialize snapshot seeding, and streamed reducer convergence against a repository-local TypeScript host.", + "targets": ["dotnet"] +} diff --git a/package-lock.json b/package-lock.json index 9170b2252..f6e9e009d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^25.5.0", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/parser": "^8.57.2", "c8": "^11.0.0", @@ -19,7 +20,8 @@ "tsx": "^4.19.0", "typescript": "^5.7.0", "vitepress": "^1.6.4", - "vitepress-plugin-mermaid": "^2.0.17" + "vitepress-plugin-mermaid": "^2.0.17", + "ws": "^8.21.3" } }, "node_modules/@algolia/abtesting": { @@ -1956,6 +1958,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -6000,6 +6012,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 0a90fdbbd..4dc09a417 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "homepage": "https://github.com/microsoft/agent-host-protocol#readme", "devDependencies": { "@types/node": "^25.5.0", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/parser": "^8.57.2", "c8": "^11.0.0", @@ -50,6 +51,7 @@ "tsx": "^4.19.0", "typescript": "^5.7.0", "vitepress": "^1.6.4", - "vitepress-plugin-mermaid": "^2.0.17" + "vitepress-plugin-mermaid": "^2.0.17", + "ws": "^8.21.3" } }