agui-dotnet-wire-types
GitHub用于在AG-UI .NET SDK中新增或修改协议类型,确保与TypeScript参考实现兼容,处理序列化上下文、转换器及公共API更新。
Trigger Scenarios
Install
npx skills add ag-ui-protocol/ag-ui --skill agui-dotnet-wire-types -g -y
SKILL.md
Frontmatter
{
"name": "agui-dotnet-wire-types",
"description": "Add or modify a wire\/protocol type in the AG-UI .NET SDK AGUI.Abstractions package — a new event, message, or content-part type, the AOT source-gen serializer context, or a polymorphic JSON converter, keeping it AOT-safe, JSON-wire-compatible with the TypeScript reference, and PublicAPI-clean. USE FOR: adding an AG-UI event type, adding a message role or input-content type, editing AGUIJsonSerializerContext, editing BaseEventJsonConverter \/ AGUIMessageJsonConverter \/ AGUIInputContentJsonConverter, fixing PublicAPI.Unshipped analyzer build failures on protocol types, wire-format round-trip serialization. DO NOT USE FOR: writing integration\/SSE tests (use agui-dotnet-integration-tests), server hosting\/endpoint code, or non-Abstractions packages."
}
AG-UI .NET Wire/Protocol Types
Add or change a serialized protocol type in sdks/dotnet/src/AGUI.Abstractions/. These types are the JSON wire format. The TypeScript SDK is the reference — every type must round-trip against its JSON. Add // Keep in sync with sdks/typescript/packages/core/src/{events,types}.ts to each new type.
The 5 source packages are AGUI.Abstractions, AGUI.Formatting, AGUI.Protobuf, AGUI.Client, AGUI.Server. Wire types live in AGUI.Abstractions only. (sdks/dotnet/AGENTS.md is the canonical reference for the serialization rules and conventions.)
Run all commands from sdks/dotnet/.
Hard rules (forgetting any breaks the build or wire compat)
Every change to a polymorphic type touches four coordinated spots. Miss one and either the build fails or the type silently won't deserialize:
- The concrete class (
Events/,Messages/). - The discriminator constant (
AGUIEventTypes/AGUIRoles/AGUIInputContentTypes). - The source-gen context registration (
Serialization/AGUIJsonSerializerContext.cs). - The hand-written converter
ReadandWritecases (BaseEventJsonConverter/AGUIMessageJsonConverter/AGUIInputContentJsonConverter).
Plus: PublicAPI.Unshipped.txt (analyzer fails the build) and a round-trip test.
Recipe: new EVENT type
- Class in
Events/{Name}Event.cs,sealed, derivingBaseEvent. OverrideType => AGUIEventTypes.{Name}. - Constant in
Events/AGUIEventTypes.cs:public const string {Name} = "SCREAMING_SNAKE_CASE";. - Register
[JsonSerializable(typeof({Name}Event))]inAGUIJsonSerializerContext. - Converter in
BaseEventJsonConverter: add aReadswitch arm (AGUIEventTypes.{Name} => jsonElement.Deserialize(options.GetTypeInfo(typeof({Name}Event))) as {Name}Event) and aWritecase {Name}Event x: JsonSerializer.Serialize(writer, x, options.GetTypeInfo(typeof({Name}Event))); break;. - PublicAPI — add every new public member to
src/AGUI.Abstractions/PublicAPI.Unshipped.txt(format below). - Test in
tests/AGUI.Abstractions.UnitTests/{Name}EventTest.cs(JsonDocument assertions).
[JsonPropertyName("type")]
public override string Type => AGUIEventTypes.RunStarted;
[JsonPropertyName("threadId")] // required: init to string.Empty
public string ThreadId { get; set; } = string.Empty;
[JsonPropertyName("parentRunId")] // optional: WhenWritingNull
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ParentRunId { get; set; }
[JsonPropertyName("value")] // dynamic payload: JsonElement, never object
public JsonElement? Value { get; set; }
Variation: MESSAGE type
Same shape, but: folder Messages/, derive AGUIMessage, discriminator is role keyed on AGUIRoles constants (lowercase, e.g. "assistant"), and the converter is AGUIMessageJsonConverter (Read + Write switch arms). Sync against types.ts. Most roles serialize directly; AGUIUserMessage is hand-serialized because its content is string | array — follow the existing WriteUserMessage/DeserializeUserMessage pattern if a role needs union content.
Variation: INPUT-CONTENT part
Folder Messages/, derive AGUIInputContent, discriminator is type keyed on AGUIInputContentTypes constants (lowercase, e.g. "image"), converter is AGUIInputContentJsonConverter. Also add the same arm to AGUIMessageJsonConverter.DeserializeUserMessage's inner contentType switch, since user-message arrays inline-dispatch content parts.
Serialization / AOT rules
[JsonSerializable(typeof(T))]for every new type — non-negotiable for AOT (no runtime reflection serializer).[JsonPropertyName("camelCase")]on every serialized property (explicit even though the context setsCamelCase).[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]on every optional/nullable property.- Required strings init to
string.Empty; collections init to[]. - Serialize via the generated context:
AGUIJsonSerializerContext.Default.{Type}(oroptions.GetTypeInfo(typeof(T))inside converters). - Dynamic/arbitrary payloads are
JsonElement/JsonElement?. - Never
JsonSerializer.Serialize<object>(...), never pass raw strings through unparsed, never reflection-based serialization.
Polymorphic converter pattern
Hand-written JsonConverter<TBase>, keyed on a discriminator property. BaseEventJsonConverter is the exemplar: Read deserializes to JsonElement, reads the discriminator, switches to the concrete GetTypeInfo(...), and throws JsonException on unknown/null; Write switches on the runtime type. Both directions must list every concrete type. A new abstract family needs the converter referenced via [JsonConverter(typeof(...))] on the base class.
PublicAPI.Unshipped.txt
The analyzer fails the build if a public member is missing here. Add one line per member, e.g. for an event:
AGUI.Abstractions.RunStartedEvent
AGUI.Abstractions.RunStartedEvent.RunStartedEvent() -> void
AGUI.Abstractions.RunStartedEvent.ThreadId.get -> string!
AGUI.Abstractions.RunStartedEvent.ThreadId.set -> void
override AGUI.Abstractions.RunStartedEvent.Type.get -> string!
AGUI.Abstractions.AGUIJsonSerializerContext.RunStartedEvent.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo<AGUI.Abstractions.RunStartedEvent>
! = non-null reference, ? = nullable. Don't forget the source-gen context's generated JsonTypeInfo<T> getter line.
Round-trip test (mandatory)
Assert JSON property names by parsing with JsonDocument — asserting the deserialized object alone misses camelCase/discriminator bugs. Don't compare full JSON strings; no reflection.
var json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.CustomEvent);
using var doc = JsonDocument.Parse(json);
Assert.Equal("CUSTOM", doc.RootElement.GetProperty("type").GetString());
Assert.Equal("user_preference_updated", doc.RootElement.GetProperty("name").GetString());
Also test dispatch through the base: JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.BaseEvent) then Assert.IsType<CustomEvent>(evt). For wire-format drift, add a fixture under tests/AGUI.Abstractions.UnitTests/Compatibility/ produced by the TypeScript SDK and load it via FixtureLoader ({Category}CompatibilityTest naming).
Code style / naming
sealed class(norecord); one type per file named for the type.- No tuples in public APIs — define a named type.
- Event classes
{Name}Event; event discriminatorsSCREAMING_SNAKE_CASE(member PascalCase); role/type/outcome discriminators are lowercase string constants in static classes — never enums. - Braces always;
ArgumentNullException.ThrowIfNull(...)for public validation; no///docs oninternal/private.
Validate
dotnet build
dotnet test tests/AGUI.Abstractions.UnitTests/
Version History
- 1a78c27 Current 2026-07-24 20:27


