Author cross-language interop tests that verify the AG-UI .NET SDK is wire-compatible with the TypeScript SDK — a Vitest TS client driving a C# CrossLanguage.TestServer over HTTP, both directions, including protobuf byte-parity against @ag-ui/proto. USE FOR: adding or modifying cross-language interop coverage, CrossLanguage.TestServer routes, the CrossLanguage.Vitest suite, protobuf wire parity tests, TS-client-to-C#-server or C#-client-to-TS-server scenarios. DO NOT USE FOR: .NET-only unit tests (use agui-dotnet-unit-tests), ASP.NET Core hosting integration tests (use agui-dotnet-integration-tests).
77
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
How to add tests proving the .NET AG-UI SDK interoperates with the TypeScript
SDK on the wire. The harness lives under sdks/dotnet/tests/. Read
sdks/dotnet/docs/cross-language-testing.md (design plus the "Harness reference"
section) for full background.
proto.decode(proto.encode(event)) — is the canonical normalised form
both SDKs must agree on. Assert .NET output against that, not against the raw
fixture object.AGUI.Abstractions,
AGUI.Server, AGUI.Protobuf), never the published NuGet packages.Two directions, two server processes (see the "Harness reference" section of
sdks/dotnet/docs/cross-language-testing.md):
| Direction | Driver | Target server | Runner |
|---|---|---|---|
| TS → .NET (Phase 1) | TS HttpAgent (@ag-ui/client) | C# CrossLanguage.TestServer on :8091 | Vitest |
| .NET → TS (Phase 2) | C# AGUIChatClient | TS fake-agent server (server/main.ts) on :8092 | xUnit |
Phase 1 mechanism: Vitest globalSetup
(CrossLanguage.Vitest/helpers/global-setup.ts) starts LLMock
(@copilotkit/aimock, deterministic LLM responses), then
helpers/dotnet-server.ts runs dotnet build and spawns the produced
CrossLanguage.TestServer.exe (not dotnet run — single PID for clean Windows
teardown) with OPENAI_BASE_URL pointed at LLMock. It waits for the HTTP
listener, then test files fetch the routes. Both server and LLMock stay alive
across the whole run; vitest.config.ts sets fileParallelism: false so
concurrent SDK builds don't contend for artifact locks.
Phase 2 mechanism: TsServerFixture (IAsyncLifetime, shared via
[Collection(nameof(TsServerCollection))]) shells out pnpm run server
(tsx server/main.ts) which emits canned AG-UI events via @ag-ui/encoder;
AGUIChatClient consumes them.
CrossLanguage.TestServer/ following the
endpoint pattern (MapPost → input.ToChatRequestContext(...) →
GetStreamingResponseAsync → .AsAGUIEventStreamAsync(ctx) →
TypedResults.ServerSentEvents(AgenticChatRoute.WrapAsSseItems(...))). See
ParallelToolCallsRoute.cs. For a route that must negotiate the transport
(SSE or protobuf from the Accept header), end with
AGUIResults.Events(events, httpContext, cancellationToken) instead and make
sure Program.cs registers ProtobufEventStreamFormatter as an IAGUIEventStreamFormatter — see AgenticChatRoute.cs.Program.cs (e.g. app.MapParallelToolCalls("/parallel_tool_calls")).CrossLanguageJsonSerializerContext.cs
([JsonSerializable(typeof(YourReport))]). This context is snake_case to
match the TS wire shape.fixtures/*.json keyed by userMessage /
toolCallId so LLM responses are deterministic (see
fixtures/parallel-tool-calls.json).tests/<scenario>.test.ts that drives the route with HttpAgent
and asserts on the collected event stream (see parallel-tool-calls.test.ts).(RunAgentInput) => BaseEvent[]) in server/fakeAgents.ts.server/main.ts.*.cs test in AGUI.CrossLanguage.IntegrationTests/, decorated
[Collection(nameof(TsServerCollection))], driving it with AGUIChatClient
(see AgenticChatTests.cs).ProtobufParityRoute.cs exposes three codec routes backed by AGUIProtobuf:
| Route | In | Out | Backed by |
|---|---|---|---|
POST /protobuf/encode | event JSON | raw proto bytes | AGUIProtobuf.Encode |
POST /protobuf/decode | raw proto bytes | event JSON | AGUIProtobuf.Decode |
POST /protobuf/decode-framed | 4-byte BE length-prefixed frames | event JSON array | AGUIProtobuf.ReadFramedAsync |
tests/protobuf-parity.test.ts proves parity against @ag-ui/proto
(sdks/typescript/packages/proto/src/proto.ts) and @ag-ui/encoder framing
(sdks/typescript/packages/encoder/src/encoder.ts). For each fixture
(fixtures/protobuf-events.ts):
tsBytes = proto.encode(event); reference = proto.decode(tsBytes).expect(netDecode(tsBytes)).toEqual(reference).expect(proto.decode(netEncode(event))).toEqual(reference).byteParity flag:
"strict" — scalar-only events (only string/number fields, serialised in
field-number order): assert the TS and .NET bytes are byte-for-byte equal."roundtrip" — payloads mapping to google.protobuf.Struct
(map<string, Value>): map-entry ordering is not canonical across
encoders, so do NOT assert byte equality. Assert both byte streams decode
(via the TS codec) to the same event instead.A framing test concatenates encoder.encodeProtobuf(event) frames (4-byte BE
prefix) and posts them to /protobuf/decode-framed to exercise ReadFramedAsync.
When adding a new protobuf event, add a fixture with the correct byteParity
flag — scalar-only ⇒ strict, any object/array payload ⇒ roundtrip.
The codec-parity pattern above isolates the codecs — it never goes through the
TS HttpAgent or Accept-header negotiation. To prove the full transport path
(TS client negotiates a protocol → .NET server encodes it → TS client decodes it),
parameterize a scenario suite over both transports — the cross-language analogue of
the .NET integration tests' TransportFormat {Json, Protobuf} [Theory].
helpers/transport.ts is the shared mechanism: TRANSPORTS = ["sse", "protobuf"],
TRANSPORT_MEDIA_TYPE, and createTransportAgent(config, transport) which returns
an HttpAgent that requests the transport (it opts into protobuf via the public
fetch hook — the default agent hardcodes Accept: text/event-stream after
spreading headers, so a headers option can't override it) and captures the
response Content-Type. Drive the suite with describe.each(TRANSPORTS) and assert
lastResponseContentType() === TRANSPORT_MEDIA_TYPE[transport] plus the usual
decoded-event assertions — see tests/agentic-chat.test.ts.
describe.each(TRANSPORTS)("… [%s]", (transport) => {
it("…", async () => {
const { agent, lastResponseContentType } = createTransportAgent(
{ url: `${baseUrl()}/agentic_chat`, threadId: `t-${transport}` }, transport);
agent.messages = [{ id: "u", role: "user", content: "Hi, I am duaa" }];
const events: BaseEvent[] = [];
await agent.runAgent({}, { onEvent: ({ event }) => events.push(event) });
expect(lastResponseContentType()).toBe(TRANSPORT_MEDIA_TYPE[transport]);
// …assert decoded events (identical regardless of transport)…
});
});The route under test must negotiate (AGUIResults.Events, with
ProtobufEventStreamFormatter registered as an IAGUIEventStreamFormatter in
Program.cs). tests/agentic-chat.test.ts and
tests/state-events.test.ts are parameterized this way today.
Only parameterize protobuf-safe scenarios. ToolCallResult, Reasoning*, and
Activity* events have no message/oneof entry in the shared events.proto (the
one schema referenced by both @ag-ui/proto and .NET AGUI.Protobuf), so neither
SDK can protobuf-encode them — the codec throwing NotSupportedException mirrors
the schema, it is not a .NET gap. A suite that emits any of those must stay SSE-only.
Adding protobuf support for them is an upstream schema change (extend the canonical
TS events.proto, regenerate both SDKs, add mappers) — see agui-cross-sdk-parity.
AGUI_MEDIA_TYPE (@ag-ui/proto) and ProtobufEventStreamFormatter.ProtobufMediaType are the identical
exact string, and AGUIEventStreamResult sets Content-Type to it with no charset
— the client's exact === content-type match depends on that.
# TS → .NET + protobuf parity (builds + spawns the C# server automatically)
cd sdks/dotnet/tests/CrossLanguage.Vitest
pnpm test
pnpm exec vitest run tests/protobuf-parity.test.ts # just the parity suite
# .NET → TS (shells out `pnpm run server`)
cd sdks/dotnet/tests/AGUI.CrossLanguage.IntegrationTests
dotnet testpnpm install from the repo root once first. Requires .NET 10 SDK, Node 20+,
pnpm 10+.
google.protobuf.Struct
map ordering is non-canonical; byte parity is only valid for scalar-only
("strict") fixtures. For object payloads, assert round-trip equivalence.proto.decode(proto.encode(event)), not the raw event object.Program.cs AND
CrossLanguageJsonSerializerContext.cs (when it introduces new payload
types) — the server won't map the route or will fail AOT-safe serialization.userMessage/toolCallId; add a fixture for every new
prompt. Use fixed/deterministic tool outputs (see ParallelToolCallsRoute's
frozen clock).dotnet run to start the server in helpers, and don't assume
parallel test files. The harness spawns the built .exe for a single
killable PID and runs files sequentially (fileParallelism: false) to avoid
build-lock contention and Windows port orphans.34c3e0c
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.