.NET: Fix Usage and Annotations not present in AG-UI events (#3752)#4114
.NET: Fix Usage and Annotations not present in AG-UI events (#3752)#4114kallebelins wants to merge 3 commits intomicrosoft:mainfrom
Conversation
…t#3752) UsageContent and TextContent.Annotations from Microsoft.Extensions.AI were silently dropped when converting ChatResponseUpdate streams to AG-UI SSE events via AsAGUIEventStreamAsync. Both content types fell through all if/else branches without matching, producing no AG-UI events. Changes: - Add CustomEvent type (CUSTOM) aligned with the AG-UI protocol spec (ag_ui.core.CustomEvent), with Name and Value (JsonElement?) properties - Handle UsageContent: emit CustomEvent(name=usage, value={inputTokenCount, outputTokenCount, totalTokenCount}) - Handle TextContent.Annotations: emit CustomEvent(name=annotations, value=[{type, title, url, fileId, toolName, snippet}]) for CitationAnnotation - Register CustomEvent in AGUIEventTypes, AGUIJsonSerializerContext (AOT), and BaseEventJsonConverter (read/write) - Add 9 regression tests in UsageAndAnnotationsTests.cs covering usage-only, annotations-only, combined, and streaming scenarios Fixes microsoft#3752
There was a problem hiding this comment.
Pull request overview
Fixes AG-UI SSE event conversion in the .NET hosting layer so that UsageContent (token usage) and TextContent.Annotations (e.g., citations) are no longer silently dropped when streaming ChatResponseUpdate via AsAGUIEventStreamAsync.
Changes:
- Introduces an internal
CUSTOM(CustomEvent) AG-UI event type withnameandvalue. - Emits
CustomEvent(name="usage")forUsageContentandCustomEvent(name="annotations")for text annotations. - Registers
CustomEventacross event type constants, AOT serializer context, and the base event JSON converter; adds regression tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| dotnet/src/Microsoft.Agents.AI.AGUI/Shared/CustomEvent.cs | Adds CustomEvent (CUSTOM) with name + value payload. |
| dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs | Adds CUSTOM discriminator constant. |
| dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs | Registers CustomEvent for source-gen/AOT serialization. |
| dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs | Adds read/write support for CUSTOM discriminator. |
| dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs | Emits CustomEvent for usage and annotations in event stream conversion. |
| dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs | Adds regression tests for usage/annotations emission across scenarios. |
| return new CustomEvent | ||
| { | ||
| Name = "usage", | ||
| Value = JsonSerializer.Deserialize(buffer.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) as JsonElement? | ||
| }; |
There was a problem hiding this comment.
The JsonSerializer.Deserialize(... ) as JsonElement? pattern is hard to read and inconsistent with the rest of this file (which uses explicit casts to JsonElement?). Consider using JsonSerializer.Deserialize<JsonElement>(...) (or an explicit (JsonElement?) cast) to avoid the nullable-as unboxing trick and make it clearer that deserialization is expected to succeed.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| return new CustomEvent | ||
| { | ||
| Name = "annotations", | ||
| Value = JsonSerializer.Deserialize(buffer.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) as JsonElement? | ||
| }; |
There was a problem hiding this comment.
Same as above: JsonSerializer.Deserialize(... ) as JsonElement? is a non-obvious pattern and differs from the explicit casting style used elsewhere in the file. Switching to JsonSerializer.Deserialize<JsonElement>(...) (or an explicit cast) would make the intent clearer and reduce the chance of subtle nulls if the type info changes.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs
Outdated
Show resolved
Hide resolved
dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs
Outdated
Show resolved
Hide resolved
| // Assert — Annotation data (URL, title) should be present somewhere in the event stream | ||
| string allEventsJson = SerializeAllEvents(events); | ||
|
|
||
| Assert.True( | ||
| allEventsJson.Contains("example.com/astronomy-source") || | ||
| allEventsJson.Contains("Astronomy 101") || | ||
| allEventsJson.Contains("annotation"), | ||
| "Annotation data (URL: 'https://example.com/astronomy-source', Title: 'Astronomy 101') " + | ||
| "should be present in AGUI events. Annotations are being silently dropped. " + | ||
| "See https://github.com/microsoft/agent-framework/issues/3752"); |
There was a problem hiding this comment.
Similar to the usage tests, these assertions are substring-based and may produce false positives/negatives. It would be more robust to locate the emitted CustomEvent with Name == "annotations" and then assert that its Value is an array containing an element with the expected url/title fields.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
|
@copilot open a new pull request to apply changes based on the comments in this thread |
…ests/UsageAndAnnotationsTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ests/UsageAndAnnotationsTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
.NET: Fix Usage and Annotations not present in AG-UI events
UsageContentandTextContent.AnnotationsfromMicrosoft.Extensions.AIwere silently dropped when convertingChatResponseUpdatestreams to AG-UI SSE events viaAsAGUIEventStreamAsync. Both content types fell through allif/else ifbranches without matching, producing no AG-UI events.Changes:
Add
CustomEventtype (CUSTOM) aligned with the AG-UI protocol spec (ag_ui.core.CustomEvent), withNameandValue(JsonElement?) propertiesHandle
UsageContent: emitCustomEvent(name="usage", value={inputTokenCount, outputTokenCount, totalTokenCount})Handle
TextContent.Annotations: emitCustomEvent(name="annotations", value=[{type, title, url, fileId, toolName, snippet}])forCitationAnnotationRegister
CustomEventinAGUIEventTypes,AGUIJsonSerializerContext(AOT), andBaseEventJsonConverter(read/write)Add 9 regression tests in UsageAndAnnotationsTests.cs covering usage-only, annotations-only, combined, and streaming scenarios
Fixes #3752
Motivation and Context
When an LLM provider returns token usage data (
UsageContent) or citation annotations (TextContent.Annotations) in aChatResponseUpdatestream, the AG-UI hosting layer was silently discarding this information. TheAsAGUIEventStreamAsyncmethod in ChatResponseUpdateAGUIExtensions.cs had handling branches forTextContent,FunctionCallContent,FunctionResultContent, andDataContent, but no branch forUsageContent— it fell through to the end of the loop body without producing any event. Similarly,TextContent.Annotations(e.g.,CitationAnnotationwith URL, title, file references) was never read: onlyTextContent.Textwas extracted intoTextMessageContentEvent, and the annotations collection was completely ignored.This meant that AG-UI frontend clients consuming the SSE stream had no visibility into:
The gap existed in both the .NET and Python implementations (Python's
_emit_content()also returns[]for unknown content types, and_emit_text()never readscontent.annotations). This fix addresses the .NET side.The AG-UI protocol already defines a
CustomEventtype (used in the Python SDK forfunction_approval_requestandPredictState), but the .NET implementation did not have this event type. AddingCustomEventis the correct protocol-aligned approach to emit extensible metadata without requiring changes to the AG-UI spec.Description
Root cause analysis:
In
ChatResponseUpdateAGUIExtensions.AsAGUIEventStreamAsync(shared code compiled into bothMicrosoft.Agents.AI.AGUIandMicrosoft.Agents.AI.Hosting.AGUI.AspNetCore), the content dispatch logic iterated overchatResponse.Contentswith branches for:FunctionCallContent→ToolCallStartEvent/ToolCallArgsEvent/ToolCallEndEventFunctionResultContent→ToolCallResultEventDataContent→StateSnapshotEvent/StateDeltaEvent/TextMessageContentEventAny other
AIContentsubtype (includingUsageContent) was silently ignored. Additionally,TextContentwas handled separately (before theforeachloop) and onlytextContent.Textwas read — theAnnotationscollection was never accessed.Solution design:
CustomEvent(new type) — Internal event class inheritingBaseEvent, withName(string) andValue(JsonElement?) properties, using discriminator"CUSTOM". Aligned withag_ui.core.CustomEventfrom the AG-UI protocol used by the Python SDK.UsageContenthandling — Added anelse if (content is UsageContent usageContent)branch in the content dispatch loop. Serializes token counts usingUtf8JsonWriterdirectly (no reflection, AOT-safe) into a JSON object{inputTokenCount, outputTokenCount, totalTokenCount}and emits asCustomEvent(name="usage").TextContent.Annotationshandling — After emittingTextMessageContentEventfor text, checkstextContent.Annotationsand if non-empty, serializes each annotation usingUtf8JsonWriter. ForCitationAnnotation, writestype,title,url,fileId,toolName, andsnippet(all null-guarded). Emits asCustomEvent(name="annotations").Serialization registration —
CustomEventregistered in:AGUIEventTypes.Custom = "CUSTOM"(constant)AGUIJsonSerializerContext([JsonSerializable(typeof(CustomEvent))]for AOT)BaseEventJsonConverter.Read()(discriminator → type mapping)BaseEventJsonConverter.Write()(concrete type → serialization)Both projects build — Since the shared code is compiled into both packages via
<Compile Include>, changes to the Shared folder automatically apply toMicrosoft.Agents.AI.AGUI(netstandard2.0/net10.0) andMicrosoft.Agents.AI.Hosting.AGUI.AspNetCore(net8.0/net9.0/net10.0).Files changed:
CUSTOMevent withNameandValuepropertiesCustom = "CUSTOM"constantCustomEventfor AOT serializationCustomEventdiscriminatorUsageContentandTextContent.AnnotationsviaCustomEvent; addedCreateUsageCustomEventandCreateAnnotationsCustomEventhelpersTest coverage:
WithUsageContent_DoesNotThrowWithUsageContentOnly_UsageDataIsNotSilentlyDroppedWithUsageContentAndText_BothContentTypesPreservedWithUsageContent_UsageTokenCountsAccessibleInEventsWithAnnotatedTextContent_DoesNotThrowWithAnnotatedTextContent_AnnotationDataNotSilentlyDroppedWithMultipleAnnotations_AllAnnotationsPreservedWithAnnotationsOnStreamingText_AnnotationsPreservedWithTextAnnotationsAndUsage_AllDataPreservedContribution Checklist
CustomEventtype and emits additional events in the SSE stream. No public API changes. Existing consumers that ignore unknown event types are unaffected.