From 8740f121eff88d68f68e9dfe29f78480e8c49107 Mon Sep 17 00:00:00 2001 From: kallebelins Date: Tue, 17 Feb 2026 17:53:45 -0300 Subject: [PATCH 1/2] .NET: Fix AG-UI hosting drops FinishReason on RunFinishedEvent (#3790) - Add FinishReason property with JsonIgnore(WhenWritingNull) to RunFinishedEvent - Propagate FinishReason from ChatResponseUpdate to RunFinishedEvent in AsAGUIEventStreamAsync - Map RunFinishedEvent.FinishReason back to ChatFinishReason in ValidateAndEmitRunFinished - Add unit tests for FinishReason round-trip in both directions (AGUI events <-> ChatResponseUpdate) - Verify JSON serialization includes finishReason when set and omits it when null Fixes #3790 --- .../ChatResponseUpdateAGUIExtensions.cs | 9 ++ .../Shared/RunFinishedEvent.cs | 4 + .../ChatResponseUpdateAGUIExtensionsTests.cs | 71 +++++++++ .../ChatResponseUpdateAGUIExtensionsTests.cs | 144 ++++++++++++++++++ 4 files changed, 228 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index f5fb103bd4..5adff3452d 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -216,6 +216,9 @@ private static ChatResponseUpdate ValidateAndEmitRunFinished(string? conversatio { ConversationId = conversationId, ResponseId = responseId, + FinishReason = !string.IsNullOrEmpty(runFinished.FinishReason) + ? new ChatFinishReason(runFinished.FinishReason) + : null, CreatedAt = DateTimeOffset.UtcNow }; } @@ -341,8 +344,13 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync( }; string? currentMessageId = null; + ChatFinishReason? lastFinishReason = null; await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { + if (chatResponse.FinishReason is not null) + { + lastFinishReason = chatResponse.FinishReason; + } if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent && !string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal)) @@ -480,6 +488,7 @@ chatResponse.Contents[0] is TextContent && { ThreadId = threadId, RunId = runId, + FinishReason = lastFinishReason?.Value, }; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs index 54aebaa333..414e90bef9 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs @@ -22,6 +22,10 @@ public RunFinishedEvent() [JsonPropertyName("runId")] public string RunId { get; set; } = string.Empty; + [JsonPropertyName("finishReason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; set; } + [JsonPropertyName("result")] public JsonElement? Result { get; set; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs index 7d40cc014d..7a231e547c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -68,6 +68,77 @@ public async Task AsChatResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponse Assert.Equal("thread1", updates[1].ConversationId); } + [Fact] + public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithFinishReasonStop_MapsToFinishReasonAsync() + { + // Arrange + List events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = "stop" } + ]; + + // Act + List updates = []; + await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + ChatResponseUpdate finishUpdate = updates[1]; + Assert.NotNull(finishUpdate.FinishReason); + Assert.Equal(ChatFinishReason.Stop.Value, finishUpdate.FinishReason.Value.Value); + } + + [Fact] + public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithFinishReasonToolCalls_MapsToFinishReasonAsync() + { + // Arrange + List events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = "tool_calls" } + ]; + + // Act + List updates = []; + await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + ChatResponseUpdate finishUpdate = updates[1]; + Assert.NotNull(finishUpdate.FinishReason); + Assert.Equal(ChatFinishReason.ToolCalls.Value, finishUpdate.FinishReason.Value.Value); + } + + [Fact] + public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithNullFinishReason_MapsToNullFinishReasonAsync() + { + // Arrange + List events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = null } + ]; + + // Act + List updates = []; + await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + ChatResponseUpdate finishUpdate = updates[1]; + Assert.Null(finishUpdate.FinishReason); + } + [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs index bf2aa6fb0b..353bb96a1e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -283,4 +283,148 @@ public async Task AsAGUIEventStreamAsync_WithMixedContentTypes_EmitsAllEventType Assert.Contains(events, e => e is ToolCallEndEvent); Assert.Contains(events, e => e is RunFinishedEvent); } + + [Fact] + public async Task AsAGUIEventStreamAsync_RunFinishedEvent_ContainsFinishReason_StopAsync() + { + // Arrange — Simulate an agent run that completes normally with FinishReason.Stop + const string ThreadId = "thread1"; + const string RunId = "run1"; + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Hello world") { MessageId = "msg1" }, + new ChatResponseUpdate(ChatRole.Assistant, "!") { MessageId = "msg1", FinishReason = ChatFinishReason.Stop } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + RunFinishedEvent finishEvent = Assert.IsType(events.Last()); + Assert.Equal(ChatFinishReason.Stop.Value, finishEvent.FinishReason); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_RunFinishedEvent_ContainsFinishReason_ToolCallsAsync() + { + // Arrange — Simulate an agent run that ends with FinishReason.ToolCalls (client-side tool call) + const string ThreadId = "thread1"; + const string RunId = "run1"; + FunctionCallContent functionCall = new("call_1", "GetWeather", new Dictionary { ["city"] = "Seattle" }); + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [functionCall]) + { + MessageId = "msg1", + FinishReason = ChatFinishReason.ToolCalls + } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + RunFinishedEvent finishEvent = Assert.IsType(events.Last()); + Assert.Equal(ChatFinishReason.ToolCalls.Value, finishEvent.FinishReason); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_RunFinishedEvent_FinishReasonNull_WhenNoUpdatesAsync() + { + // Arrange — No ChatResponseUpdates emitted (empty stream) + const string ThreadId = "thread1"; + const string RunId = "run1"; + List updates = []; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + RunFinishedEvent finishEvent = Assert.IsType(events.Last()); + Assert.Null(finishEvent.FinishReason); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_RunFinishedEvent_CapturesLastFinishReasonAsync() + { + // Arrange — Multiple updates, only the last one has FinishReason set. + // Verifies that FinishReason is captured from the last update that has it. + const string ThreadId = "thread1"; + const string RunId = "run1"; + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Part 1") { MessageId = "msg1" }, + new ChatResponseUpdate(ChatRole.Assistant, "Part 2") { MessageId = "msg1" }, + new ChatResponseUpdate(ChatRole.Assistant, "Part 3") { MessageId = "msg1", FinishReason = ChatFinishReason.Stop } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + RunFinishedEvent finishEvent = Assert.IsType(events.Last()); + Assert.Equal(ChatFinishReason.Stop.Value, finishEvent.FinishReason); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_RunFinishedEvent_FinishReason_SerializedCorrectlyAsync() + { + // Arrange — Verify the FinishReason is serialized in the JSON output per AG-UI spec + const string ThreadId = "thread1"; + const string RunId = "run1"; + List updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Done") { MessageId = "msg1", FinishReason = ChatFinishReason.Stop } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert — Serialize the RunFinishedEvent and verify finishReason is present in JSON + RunFinishedEvent finishEvent = Assert.IsType(events.Last()); + string json = System.Text.Json.JsonSerializer.Serialize(finishEvent, AGUIJsonSerializerContext.Default.Options.GetTypeInfo(typeof(RunFinishedEvent))); + Assert.Contains("\"finishReason\"", json); + Assert.Contains("\"stop\"", json); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_RunFinishedEvent_FinishReasonNull_OmittedFromSerializationAsync() + { + // Arrange — When FinishReason is null, it should be omitted from JSON serialization + const string ThreadId = "thread1"; + const string RunId = "run1"; + List updates = []; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert — Serialize the RunFinishedEvent and verify finishReason is NOT present in JSON + RunFinishedEvent finishEvent = Assert.IsType(events.Last()); + string json = System.Text.Json.JsonSerializer.Serialize(finishEvent, AGUIJsonSerializerContext.Default.Options.GetTypeInfo(typeof(RunFinishedEvent))); + Assert.DoesNotContain("\"finishReason\"", json); + } } From f157597abc841f22ca78c890d90474dcbb4bce19 Mon Sep 17 00:00:00 2001 From: kallebelins <44676581+kallebelins@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:18:18 -0300 Subject: [PATCH 2/2] Update dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs Suggestion Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatResponseUpdateAGUIExtensionsTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs index 7a231e547c..831ea1d0ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -139,6 +139,29 @@ public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithNullFinishReas Assert.Null(finishUpdate.FinishReason); } + [Fact] + public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithEmptyFinishReason_MapsToNullFinishReasonAsync() + { + // Arrange + List events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = string.Empty } + ]; + + // Act + List updates = []; + await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + ChatResponseUpdate finishUpdate = updates[1]; + Assert.Null(finishUpdate.FinishReason); + } + [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync() {