Skip to content

.NET: Fix Usage and Annotations not present in AG-UI events (#3752)#4114

Open
kallebelins wants to merge 3 commits intomicrosoft:mainfrom
kallebelins:copilot/fix-agui-usage-annotations-not-present-3752
Open

.NET: Fix Usage and Annotations not present in AG-UI events (#3752)#4114
kallebelins wants to merge 3 commits intomicrosoft:mainfrom
kallebelins:copilot/fix-agui-usage-annotations-not-present-3752

Conversation

@kallebelins
Copy link

.NET: Fix Usage and Annotations not present in AG-UI events

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 if 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 #3752

Motivation and Context

When an LLM provider returns token usage data (UsageContent) or citation annotations (TextContent.Annotations) in a ChatResponseUpdate stream, the AG-UI hosting layer was silently discarding this information. The AsAGUIEventStreamAsync method in ChatResponseUpdateAGUIExtensions.cs had handling branches for TextContent, FunctionCallContent, FunctionResultContent, and DataContent, but no branch for UsageContent — it fell through to the end of the loop body without producing any event. Similarly, TextContent.Annotations (e.g., CitationAnnotation with URL, title, file references) was never read: only TextContent.Text was extracted into TextMessageContentEvent, and the annotations collection was completely ignored.

This meant that AG-UI frontend clients consuming the SSE stream had no visibility into:

  1. Token consumption — input/output/total token counts returned by the model, needed for cost tracking, rate limiting, and UX display
  2. Source citations — URLs, document titles, file IDs, and snippets that the model referenced in its response, needed for attribution, transparency, and grounded AI experiences

The gap existed in both the .NET and Python implementations (Python's _emit_content() also returns [] for unknown content types, and _emit_text() never reads content.annotations). This fix addresses the .NET side.

The AG-UI protocol already defines a CustomEvent type (used in the Python SDK for function_approval_request and PredictState), but the .NET implementation did not have this event type. Adding CustomEvent is 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 both Microsoft.Agents.AI.AGUI and Microsoft.Agents.AI.Hosting.AGUI.AspNetCore), the content dispatch logic iterated over chatResponse.Contents with branches for:

  • FunctionCallContentToolCallStartEvent / ToolCallArgsEvent / ToolCallEndEvent
  • FunctionResultContentToolCallResultEvent
  • DataContentStateSnapshotEvent / StateDeltaEvent / TextMessageContentEvent

Any other AIContent subtype (including UsageContent) was silently ignored. Additionally, TextContent was handled separately (before the foreach loop) and only textContent.Text was read — the Annotations collection was never accessed.

Solution design:

  1. CustomEvent (new type) — Internal event class inheriting BaseEvent, with Name (string) and Value (JsonElement?) properties, using discriminator "CUSTOM". Aligned with ag_ui.core.CustomEvent from the AG-UI protocol used by the Python SDK.

  2. UsageContent handling — Added an else if (content is UsageContent usageContent) branch in the content dispatch loop. Serializes token counts using Utf8JsonWriter directly (no reflection, AOT-safe) into a JSON object {inputTokenCount, outputTokenCount, totalTokenCount} and emits as CustomEvent(name="usage").

  3. TextContent.Annotations handling — After emitting TextMessageContentEvent for text, checks textContent.Annotations and if non-empty, serializes each annotation using Utf8JsonWriter. For CitationAnnotation, writes type, title, url, fileId, toolName, and snippet (all null-guarded). Emits as CustomEvent(name="annotations").

  4. Serialization registrationCustomEvent registered in:

    • AGUIEventTypes.Custom = "CUSTOM" (constant)
    • AGUIJsonSerializerContext ([JsonSerializable(typeof(CustomEvent))] for AOT)
    • BaseEventJsonConverter.Read() (discriminator → type mapping)
    • BaseEventJsonConverter.Write() (concrete type → serialization)
  5. Both projects build — Since the shared code is compiled into both packages via <Compile Include>, changes to the Shared folder automatically apply to Microsoft.Agents.AI.AGUI (netstandard2.0/net10.0) and Microsoft.Agents.AI.Hosting.AGUI.AspNetCore (net8.0/net9.0/net10.0).

Files changed:

File Type Description
CustomEvent.cs New CUSTOM event with Name and Value properties
AGUIEventTypes.cs Modified Added Custom = "CUSTOM" constant
AGUIJsonSerializerContext.cs Modified Registered CustomEvent for AOT serialization
BaseEventJsonConverter.cs Modified Added Read/Write for CustomEvent discriminator
ChatResponseUpdateAGUIExtensions.cs Modified Handle UsageContent and TextContent.Annotations via CustomEvent; added CreateUsageCustomEvent and CreateAnnotationsCustomEvent helpers
UsageAndAnnotationsTests.cs New 9 regression tests

Test coverage:

Test Scenario
WithUsageContent_DoesNotThrow UsageContent alongside text doesn't crash
WithUsageContentOnly_UsageDataIsNotSilentlyDropped UsageContent-only stream produces events beyond lifecycle
WithUsageContentAndText_BothContentTypesPreserved Text + usage both produce their respective events
WithUsageContent_UsageTokenCountsAccessibleInEvents Token counts (500/200/700) appear in serialized events
WithAnnotatedTextContent_DoesNotThrow CitationAnnotation on text doesn't crash
WithAnnotatedTextContent_AnnotationDataNotSilentlyDropped URL and title appear in serialized events
WithMultipleAnnotations_AllAnnotationsPreserved Multiple citations all preserved
WithAnnotationsOnStreamingText_AnnotationsPreserved Annotations on streaming deltas preserved
WithTextAnnotationsAndUsage_AllDataPreserved Combined text + annotations + usage all present

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? No. Adds new internal CustomEvent type and emits additional events in the SSE stream. No public API changes. Existing consumers that ignore unknown event types are unaffected.

…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
Copilot AI review requested due to automatic review settings February 20, 2026 02:35
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with name and value.
  • Emits CustomEvent(name="usage") for UsageContent and CustomEvent(name="annotations") for text annotations.
  • Registers CustomEvent across 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.

Comment on lines +520 to +524
return new CustomEvent
{
Name = "usage",
Value = JsonSerializer.Deserialize(buffer.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) as JsonElement?
};
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +574 to +578
return new CustomEvent
{
Name = "annotations",
Value = JsonSerializer.Deserialize(buffer.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) as JsonElement?
};
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +231 to +240
// 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");
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

@kallebelins
Copy link
Author

@copilot open a new pull request to apply changes based on the comments in this thread

kallebelins and others added 2 commits February 20, 2026 00:17
…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>
@kallebelins
Copy link
Author

@copilot open a new pull request to apply changes based on the comments in this thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Bug]: [AG-UI] Usage and Annotations are not present

2 participants

Comments