agui-dotnet-cross-language-tests
GitHub用于编写和验证 AG-UI .NET SDK 与 TypeScript SDK 之间的跨语言互操作性测试,涵盖双向通信及 Protobuf 字节级一致性。
Trigger Scenarios
Install
npx skills add ag-ui-protocol/ag-ui --skill agui-dotnet-cross-language-tests -g -y
SKILL.md
Frontmatter
{
"name": "agui-dotnet-cross-language-tests",
"description": "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)."
}
AG-UI .NET Cross-Language Interop Tests
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.
Interop philosophy
- The TypeScript SDK is the reference (oracle). A round-trip through the TS
codec —
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. - Round-trip semantic equivalence is the real guarantee, in both directions (TS→.NET and .NET→TS). Byte-for-byte parity is a stronger claim asserted only where deterministic.
- Tests exercise our code via project references (
AGUI.Abstractions,AGUI.Server,AGUI.Protobuf), never the published NuGet packages.
Harness architecture
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.
Adding a TS → .NET scenario (Phase 1)
- Add a server-side route under
CrossLanguage.TestServer/following the endpoint pattern (MapPost→input.ToChatRequestContext(...)→GetStreamingResponseAsync→.AsAGUIEventStreamAsync(ctx)→TypedResults.ServerSentEvents(AgenticChatRoute.WrapAsSseItems(...))). SeeParallelToolCallsRoute.cs. For a route that must negotiate the transport (SSE or protobuf from theAcceptheader), end withAGUIResults.Events(events, httpContext, cancellationToken)instead and make sureProgram.csregistersProtobufEventStreamFormatteras anIAGUIEventStreamFormatter— seeAgenticChatRoute.cs. - Register it in
Program.cs(e.g.app.MapParallelToolCalls("/parallel_tool_calls")). - Register any new payload types in
CrossLanguageJsonSerializerContext.cs([JsonSerializable(typeof(YourReport))]). This context is snake_case to match the TS wire shape. - Add an aimock fixture in
fixtures/*.jsonkeyed byuserMessage/toolCallIdso LLM responses are deterministic (seefixtures/parallel-tool-calls.json). - Add a
tests/<scenario>.test.tsthat drives the route withHttpAgentand asserts on the collected event stream (seeparallel-tool-calls.test.ts).
Adding a .NET → TS scenario (Phase 2)
- Add a fake agent (
(RunAgentInput) => BaseEvent[]) inserver/fakeAgents.ts. - Mount its route in
server/main.ts. - Add a
*.cstest inAGUI.CrossLanguage.IntegrationTests/, decorated[Collection(nameof(TsServerCollection))], driving it withAGUIChatClient(seeAgenticChatTests.cs).
Protobuf wire-parity pattern
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).- TS encode → .NET decode:
expect(netDecode(tsBytes)).toEqual(reference). - .NET encode → TS decode:
expect(proto.decode(netEncode(event))).toEqual(reference). - Byte parity depends on the fixture's
byteParityflag:"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 togoogle.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.
Transport parity (parameterize scenarios over SSE + protobuf)
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.
Running the suites
# 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 test
pnpm install from the repo root once first. Requires .NET 10 SDK, Node at the
version in the repository's root .node-version, pnpm 10+.
❌ Critical anti-patterns
- Don't assert byte-equality for object/Struct payloads.
google.protobuf.Structmap ordering is non-canonical; byte parity is only valid for scalar-only ("strict") fixtures. For object payloads, assert round-trip equivalence. - Don't make the .NET fixture the oracle. The TS codec is the reference —
compare against
proto.decode(proto.encode(event)), not the raw event object. - Don't add a route without registering it in BOTH
Program.csANDCrossLanguageJsonSerializerContext.cs(when it introduces new payload types) — the server won't map the route or will fail AOT-safe serialization. - Don't depend on real LLM calls. Phase 1 LLM responses come from aimock
fixtures keyed by
userMessage/toolCallId; add a fixture for every new prompt. Use fixed/deterministic tool outputs (seeParallelToolCallsRoute's frozen clock). - Don't use
dotnet runto start the server in helpers, and don't assume parallel test files. The harness spawns the built.exefor a single killable PID and runs files sequentially (fileParallelism: false) to avoid build-lock contention and Windows port orphans.
Version History
-
3bba136
Current 2026-08-19 21:19
文档化 Node 工具链版本管理方式及更新规范
- 1a78c27 2026-07-24 20:27


