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..831ea1d0ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -68,6 +68,100 @@ 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_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() { 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); + } }