From b8381cb61647c2f8678d18342d569624ac09a608 Mon Sep 17 00:00:00 2001 From: kallebelins Date: Thu, 19 Feb 2026 23:31:41 -0300 Subject: [PATCH 1/3] .NET: Fix Usage and Annotations not present in AG-UI events (#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 #3752 --- .../Shared/AGUIEventTypes.cs | 2 + .../Shared/AGUIJsonSerializerContext.cs | 1 + .../Shared/BaseEventJsonConverter.cs | 4 + .../ChatResponseUpdateAGUIExtensions.cs | 84 ++++ .../Shared/CustomEvent.cs | 24 + .../UsageAndAnnotationsTests.cs | 412 ++++++++++++++++++ 6 files changed, 527 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/CustomEvent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs index 1b8958cdf0..ea646e4a3f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs @@ -31,4 +31,6 @@ internal static class AGUIEventTypes public const string StateSnapshot = "STATE_SNAPSHOT"; public const string StateDelta = "STATE_DELTA"; + + public const string Custom = "CUSTOM"; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs index b13a803625..2e5f26f8d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs @@ -46,6 +46,7 @@ namespace Microsoft.Agents.AI.AGUI; [JsonSerializable(typeof(ToolCallResultEvent))] [JsonSerializable(typeof(StateSnapshotEvent))] [JsonSerializable(typeof(StateDeltaEvent))] +[JsonSerializable(typeof(CustomEvent))] [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs index eca2131f23..90df435938 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs @@ -47,6 +47,7 @@ public override BaseEvent Read( AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent, AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent, AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent, + AGUIEventTypes.Custom => jsonElement.Deserialize(options.GetTypeInfo(typeof(CustomEvent))) as CustomEvent, _ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'") }; @@ -102,6 +103,9 @@ public override void Write( case StateDeltaEvent stateDelta: JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent))); break; + case CustomEvent customEvent: + JsonSerializer.Serialize(writer, customEvent, options.GetTypeInfo(typeof(CustomEvent))); + break; default: throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}"); } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index f5fb103bd4..ae7ba9082d 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -375,6 +375,12 @@ chatResponse.Contents[0] is TextContent && MessageId = chatResponse.MessageId!, Delta = textContent.Text }; + + // Emit annotations if present on the text content + if (textContent.Annotations is { Count: > 0 }) + { + yield return CreateAnnotationsCustomEvent(textContent.Annotations, jsonSerializerOptions); + } } // Emit tool call events and tool result events @@ -463,6 +469,11 @@ chatResponse.Contents[0] is TextContent && }; } } + else if (content is UsageContent usageContent) + { + // Emit usage data as a custom event + yield return CreateUsageCustomEvent(usageContent, jsonSerializerOptions); + } } } } @@ -493,4 +504,77 @@ chatResponse.Contents[0] is TextContent && _ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())), }; } + + private static CustomEvent CreateUsageCustomEvent(UsageContent usageContent, JsonSerializerOptions jsonSerializerOptions) + { + using var buffer = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteNumber("inputTokenCount", usageContent.Details.InputTokenCount ?? 0); + writer.WriteNumber("outputTokenCount", usageContent.Details.OutputTokenCount ?? 0); + writer.WriteNumber("totalTokenCount", usageContent.Details.TotalTokenCount ?? 0); + writer.WriteEndObject(); + } + + return new CustomEvent + { + Name = "usage", + Value = JsonSerializer.Deserialize(buffer.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) as JsonElement? + }; + } + + private static CustomEvent CreateAnnotationsCustomEvent( + IList annotations, + JsonSerializerOptions jsonSerializerOptions) + { + using var buffer = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartArray(); + foreach (var annotation in annotations) + { + writer.WriteStartObject(); + writer.WriteString("type", annotation.GetType().Name); + + if (annotation is CitationAnnotation citation) + { + if (citation.Title is not null) + { + writer.WriteString("title", citation.Title); + } + + if (citation.Url is not null) + { + writer.WriteString("url", citation.Url.ToString()); + } + + if (citation.FileId is not null) + { + writer.WriteString("fileId", citation.FileId); + } + + if (citation.ToolName is not null) + { + writer.WriteString("toolName", citation.ToolName); + } + + if (citation.Snippet is not null) + { + writer.WriteString("snippet", citation.Snippet); + } + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + return new CustomEvent + { + Name = "annotations", + Value = JsonSerializer.Deserialize(buffer.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) as JsonElement? + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/CustomEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/CustomEvent.cs new file mode 100644 index 0000000000..1ad59688af --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/CustomEvent.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +#if ASPNETCORE +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; +#else +namespace Microsoft.Agents.AI.AGUI.Shared; +#endif + +internal sealed class CustomEvent : BaseEvent +{ + public CustomEvent() + { + this.Type = AGUIEventTypes.Custom; + } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("value")] + public JsonElement? Value { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs new file mode 100644 index 0000000000..5545c153ec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Tests verifying that Usage (token consumption) and Annotations (citations, file references) +/// are properly preserved when converting ChatResponseUpdates to AG-UI SSE events. +/// Regression tests for https://github.com/microsoft/agent-framework/issues/3752. +/// +public sealed class UsageAndAnnotationsTests +{ + private const string ThreadId = "thread1"; + private const string RunId = "run1"; + + #region Usage Tests + + [Fact] + public async Task AsAGUIEventStreamAsync_WithUsageContent_DoesNotThrowAsync() + { + // Arrange — ChatResponseUpdate containing UsageContent alongside text + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg1" }, + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 100, + OutputTokenCount = 50, + TotalTokenCount = 150 + })], + MessageId = "msg1" + } + ]; + + // Act — Should not throw even if UsageContent is not mapped to an event + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert — Stream should at least contain lifecycle events + Assert.Contains(events, e => e is RunStartedEvent); + Assert.Contains(events, e => e is RunFinishedEvent); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithUsageContentOnly_UsageDataIsNotSilentlyDroppedAsync() + { + // Arrange — Baseline: empty stream produces only RunStarted + RunFinished + List baselineUpdates = []; + List baselineEvents = await CollectEventsAsync(baselineUpdates); + int baselineCount = baselineEvents.Count; + + // Test: stream with UsageContent only (no text, no tool calls) + List usageUpdates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 250, + OutputTokenCount = 120, + TotalTokenCount = 370 + })], + MessageId = "msg1" + } + ]; + List usageEvents = await CollectEventsAsync(usageUpdates); + + // Assert — Usage data should produce at least one additional event beyond lifecycle events. + // Currently (before fix), UsageContent is silently dropped and usageEvents.Count == baselineCount. + Assert.True( + usageEvents.Count > baselineCount, + "UsageContent should produce additional events beyond lifecycle events. " + + "Got " + usageEvents.Count + " events, same as baseline (" + baselineCount + "). " + + "Usage data (InputTokenCount=250, OutputTokenCount=120) is being silently dropped. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithUsageContentAndText_BothContentTypesPreservedAsync() + { + // Arrange — A realistic scenario: agent returns text + usage in the same response + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Here is the answer.") { MessageId = "msg1" }, + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 45, + OutputTokenCount = 12, + TotalTokenCount = 57 + })], + MessageId = "msg1" + } + ]; + + List events = await CollectEventsAsync(updates); + + // Assert — Text content should produce text events + Assert.Contains(events, e => e is TextMessageStartEvent); + Assert.Contains(events, e => e is TextMessageContentEvent tce && tce.Delta == "Here is the answer."); + Assert.Contains(events, e => e is TextMessageEndEvent); + + // Assert — Usage data should ALSO be present somewhere in the event stream + // Excluding lifecycle events and text events, there should be at least one event for usage + int textAndLifecycleCount = events.Count(e => + e is RunStartedEvent or RunFinishedEvent or + TextMessageStartEvent or TextMessageContentEvent or TextMessageEndEvent); + + Assert.True( + events.Count > textAndLifecycleCount, + "When both text and usage content are present, usage data should produce additional events. " + + $"Total events: {events.Count}, text+lifecycle events: {textAndLifecycleCount}. " + + "Usage data is being silently dropped. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithUsageContent_UsageTokenCountsAccessibleInEventsAsync() + { + // Arrange + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Response text") { MessageId = "msg1" }, + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 500, + OutputTokenCount = 200, + TotalTokenCount = 700 + })], + MessageId = "msg1" + } + ]; + + List events = await CollectEventsAsync(updates); + + // Assert — Serialize all events to JSON and check that token counts appear + string allEventsJson = SerializeAllEvents(events); + + // The token counts should be present in the serialized event stream + Assert.True( + allEventsJson.Contains("500") || allEventsJson.Contains("input"), + "InputTokenCount (500) should be present in the serialized AGUI events. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + Assert.True( + allEventsJson.Contains("200") || allEventsJson.Contains("output"), + "OutputTokenCount (200) should be present in the serialized AGUI events. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + } + + #endregion + + #region Annotations Tests + + [Fact] + public async Task AsAGUIEventStreamAsync_WithAnnotatedTextContent_DoesNotThrowAsync() + { + // Arrange — TextContent with annotations (citations) + TextContent textContent = new("According to research, the answer is 42."); + textContent.Annotations = + [ + new CitationAnnotation + { + Url = new System.Uri("https://example.com/source"), + Title = "Source Document" + } + ]; + + List updates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [textContent], + MessageId = "msg1" + } + ]; + + // Act — Should not throw even if annotations are not mapped + List events = await CollectEventsAsync(updates); + + // Assert — Text content should still be emitted + Assert.Contains(events, e => e is TextMessageContentEvent tce && tce.Delta.Contains("42")); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithAnnotatedTextContent_AnnotationDataNotSilentlyDroppedAsync() + { + // Arrange — TextContent with citation annotations + TextContent textContent = new("The earth orbits the sun."); + textContent.Annotations = + [ + new CitationAnnotation + { + Url = new System.Uri("https://example.com/astronomy-source"), + Title = "Astronomy 101" + } + ]; + + List updates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [textContent], + MessageId = "msg1" + } + ]; + + List events = await CollectEventsAsync(updates); + + // 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"); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithMultipleAnnotations_AllAnnotationsPreservedAsync() + { + // Arrange — TextContent with multiple citation annotations + TextContent textContent = new("Summary based on multiple sources."); + textContent.Annotations = + [ + new CitationAnnotation + { + Url = new System.Uri("https://source-a.com/doc"), + Title = "Source A" + }, + new CitationAnnotation + { + Url = new System.Uri("https://source-b.com/paper"), + Title = "Source B" + } + ]; + + List updates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [textContent], + MessageId = "msg1" + } + ]; + + List events = await CollectEventsAsync(updates); + string allEventsJson = SerializeAllEvents(events); + + // Assert — Both annotations should be present + Assert.True( + allEventsJson.Contains("source-a.com") || allEventsJson.Contains("Source A"), + "First annotation (Source A) should be present in AGUI events. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + Assert.True( + allEventsJson.Contains("source-b.com") || allEventsJson.Contains("Source B"), + "Second annotation (Source B) should be present in AGUI events. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithAnnotationsOnStreamingText_AnnotationsPreservedAsync() + { + // Arrange — Streaming scenario: multiple text deltas, last one carries annotations + TextContent delta1 = new("Hello "); + TextContent delta2 = new("world."); + delta2.Annotations = + [ + new CitationAnnotation + { + Url = new System.Uri("https://reference.com/greeting"), + Title = "Greeting Reference" + } + ]; + + List updates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [delta1], + MessageId = "msg1" + }, + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [delta2], + MessageId = "msg1" + } + ]; + + List events = await CollectEventsAsync(updates); + string allEventsJson = SerializeAllEvents(events); + + // Assert — Annotation on the last delta should not be lost + Assert.True( + allEventsJson.Contains("reference.com/greeting") || + allEventsJson.Contains("Greeting Reference"), + "Annotations on streaming text deltas should be preserved in AGUI events. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + } + + #endregion + + #region Combined Tests + + [Fact] + public async Task AsAGUIEventStreamAsync_WithTextAnnotationsAndUsage_AllDataPreservedAsync() + { + // Arrange — Realistic scenario: text with annotations + usage data + TextContent textContent = new("The answer is documented here."); + textContent.Annotations = + [ + new CitationAnnotation + { + Url = new System.Uri("https://docs.example.com/answer"), + Title = "Answer Documentation" + } + ]; + + List updates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [textContent], + MessageId = "msg1" + }, + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 80, + OutputTokenCount = 25, + TotalTokenCount = 105 + })], + MessageId = "msg1" + } + ]; + + List events = await CollectEventsAsync(updates); + string allEventsJson = SerializeAllEvents(events); + + // Assert — Text should be present + Assert.Contains(events, e => e is TextMessageContentEvent tce && tce.Delta.Contains("documented")); + + // Assert — Annotations should be present + Assert.True( + allEventsJson.Contains("docs.example.com/answer") || allEventsJson.Contains("Answer Documentation"), + "Annotation data should be preserved in AGUI events when text and usage are both present. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + + // Assert — Usage should be present + List baselineEvents = await CollectEventsAsync([]); + int textAndLifecycleCount = events.Count(e => + e is RunStartedEvent or RunFinishedEvent or + TextMessageStartEvent or TextMessageContentEvent or TextMessageEndEvent); + Assert.True( + events.Count > textAndLifecycleCount, + "Usage data should produce additional events when combined with annotated text. " + + "See https://github.com/microsoft/agent-framework/issues/3752"); + } + + #endregion + + #region Helpers + + private static async Task> CollectEventsAsync(List updates) + { + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + return events; + } + + private static string SerializeAllEvents(List events) + { + return string.Join("\n", events.Select(e => + JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.Options))); + } + + #endregion +} From cd00892d7fe646c68abc76c9771f82af58dcb1f6 Mon Sep 17 00:00:00 2001 From: kallebelins <44676581+kallebelins@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:17:14 -0300 Subject: [PATCH 2/3] Update dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../UsageAndAnnotationsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs index 5545c153ec..7a5bcfd13c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs @@ -42,7 +42,7 @@ public async Task AsAGUIEventStreamAsync_WithUsageContent_DoesNotThrowAsync() } ]; - // Act — Should not throw even if UsageContent is not mapped to an event + // Act — Should not throw when handling UsageContent mapped to a CustomEvent List events = []; await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync() .AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) From a6e82cfd24cdf0b9f749499ad9a9e765768baede Mon Sep 17 00:00:00 2001 From: kallebelins <44676581+kallebelins@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:17:37 -0300 Subject: [PATCH 3/3] Update dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../UsageAndAnnotationsTests.cs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs index 7a5bcfd13c..a0f751fb6c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/UsageAndAnnotationsTests.cs @@ -153,18 +153,29 @@ public async Task AsAGUIEventStreamAsync_WithUsageContent_UsageTokenCountsAccess List events = await CollectEventsAsync(updates); - // Assert — Serialize all events to JSON and check that token counts appear - string allEventsJson = SerializeAllEvents(events); - - // The token counts should be present in the serialized event stream - Assert.True( - allEventsJson.Contains("500") || allEventsJson.Contains("input"), - "InputTokenCount (500) should be present in the serialized AGUI events. " + - "See https://github.com/microsoft/agent-framework/issues/3752"); - Assert.True( - allEventsJson.Contains("200") || allEventsJson.Contains("output"), - "OutputTokenCount (200) should be present in the serialized AGUI events. " + + // Assert — find the usage custom event and verify its payload contains the expected token counts + List usageEvents = events + .OfType() + .Where(e => e.Name == "usage") + .ToList(); + + Assert.Single( + usageEvents, + "Exactly one usage custom event should be present in the AGUI events. " + "See https://github.com/microsoft/agent-framework/issues/3752"); + + string usageJson = usageEvents[0].Value ?? string.Empty; + + // The token counts should be present in the usage event payload + Assert.Contains( + "500", + usageJson); + Assert.Contains( + "200", + usageJson); + Assert.Contains( + "700", + usageJson); } #endregion