Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 8 additions & 11 deletions clients/dotnet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions clients/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Runs the real .NET WebSocket transport and client against a repository-local
/// TypeScript host that owns the authoritative state through <c>sessionReducer</c>.
/// This keeps negotiation, initialize snapshot seeding, streaming, and reducer
/// convergence inside one hermetic CI lane.
/// </summary>
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<SessionState>(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<Dictionary<string, string>, JsonElement>(
"interop/run",
new Dictionary<string, string> { ["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<SubscriptionEventAction>(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<string> _stderr;

private TypeScriptHost(Process process, Task<string> stderr, int port)
{
_process = process;
_stderr = stderr;
Uri = new Uri($"ws://127.0.0.1:{port}");
}

public Uri Uri { get; }

public static async Task<TypeScriptHost> 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.");
}
}
}
157 changes: 157 additions & 0 deletions clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts
Original file line number Diff line number Diff line change
@@ -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<JsonRpcRequest>;
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);
12 changes: 12 additions & 0 deletions clients/dotnet/tests/AgentHostProtocol.Tests/interop/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node", "ws"]
},
"files": ["session-host.ts"]
}
Loading