diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 8d7c9febb7..5004a32cfa 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -38,6 +38,7 @@ jobs: . .github dotnet + python/packages/devui/agent_framework_devui/ui - name: Get changed files id: changed-files diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index f5fb103bd4..289d8b07f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -406,9 +406,16 @@ chatResponse.Contents[0] is TextContent && } else if (content is FunctionResultContent functionResultContent) { + // AG-UI requires each TOOL_CALL_RESULT event to carry a unique messageId, + // but multiple FunctionResultContent items within a single ChatResponseUpdate + // share the same MessageId (per M.E.AI semantics, which groups updates into + // logical messages). We compose a deterministic unique id by combining the + // original MessageId with the CallId, preserving traceability back to the + // source ChatResponseUpdate while satisfying the AG-UI uniqueness constraint. + // See: https://github.com/microsoft/agent-framework/issues/3962 yield return new ToolCallResultEvent { - MessageId = chatResponse.MessageId, + MessageId = $"{chatResponse.MessageId!}_{functionResultContent.CallId}", ToolCallId = functionResultContent.CallId, Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "", Role = AGUIRoles.Tool 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..de759b7292 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 @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -283,4 +284,174 @@ 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_WithFunctionResultContent_EmitsToolCallResultEventAsync() + { + // Arrange + const string ThreadId = "thread1"; + const string RunId = "run1"; + FunctionResultContent result = new("call_123", "Sunny, 72°F"); + List updates = + [ + new ChatResponseUpdate(ChatRole.Tool, [result]) { MessageId = "msg1" } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + ToolCallResultEvent? resultEvent = events.OfType().FirstOrDefault(); + Assert.NotNull(resultEvent); + Assert.Equal("call_123", resultEvent.ToolCallId); + Assert.NotNull(resultEvent.MessageId); + Assert.NotEmpty(resultEvent.MessageId); + Assert.Equal(AGUIRoles.Tool, resultEvent.Role); + Assert.Contains("Sunny", resultEvent.Content); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_ConsecutiveToolCallResults_HaveDistinctMessageIdsAsync() + { + // Arrange — Issue #3962: MapAGUI reuses the same messageId for consecutive TOOL_CALL_RESULT SSE events + // When an agent executes 2+ server-side tools, the FunctionResultContent items returned + // in the same ChatResponseUpdate share the same MessageId. The AG-UI spec requires each + // TOOL_CALL_RESULT event to have a distinct messageId. + const string ThreadId = "thread1"; + const string RunId = "run1"; + const string SharedMessageId = "msg_shared"; + + FunctionResultContent result1 = new("call_1", "Sunny, 72°F"); + FunctionResultContent result2 = new("call_2", "3:45 PM"); + + // Both results come from the same ChatResponseUpdate with the same MessageId + // (this is what happens when the LLM returns multiple tool results in one response) + List updates = + [ + new ChatResponseUpdate(ChatRole.Tool, [result1, result2]) { MessageId = SharedMessageId } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + List toolCallResultEvents = events.OfType().ToList(); + Assert.Equal(2, toolCallResultEvents.Count); + + // Each TOOL_CALL_RESULT must have a distinct, non-null messageId + Assert.All(toolCallResultEvents, e => Assert.NotNull(e.MessageId)); + Assert.All(toolCallResultEvents, e => Assert.NotEmpty(e.MessageId!)); + Assert.NotEqual(toolCallResultEvents[0].MessageId, toolCallResultEvents[1].MessageId); + + // Verify the tool call IDs are preserved correctly + Assert.Contains(toolCallResultEvents, e => e.ToolCallId == "call_1"); + Assert.Contains(toolCallResultEvents, e => e.ToolCallId == "call_2"); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_ToolCallResultsFromSeparateUpdates_HaveDistinctMessageIdsAsync() + { + // Arrange — Variant of #3962: tool results arriving in separate ChatResponseUpdate objects + // but with the same MessageId should still get distinct messageIds in the SSE events. + const string ThreadId = "thread1"; + const string RunId = "run1"; + const string SharedMessageId = "msg_shared"; + + FunctionResultContent result1 = new("call_1", "Sunny"); + FunctionResultContent result2 = new("call_2", "3:45 PM"); + + List updates = + [ + new ChatResponseUpdate(ChatRole.Tool, [result1]) { MessageId = SharedMessageId }, + new ChatResponseUpdate(ChatRole.Tool, [result2]) { MessageId = SharedMessageId } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + List toolCallResultEvents = events.OfType().ToList(); + Assert.Equal(2, toolCallResultEvents.Count); + + Assert.All(toolCallResultEvents, e => Assert.NotNull(e.MessageId)); + Assert.All(toolCallResultEvents, e => Assert.NotEmpty(e.MessageId!)); + Assert.NotEqual(toolCallResultEvents[0].MessageId, toolCallResultEvents[1].MessageId); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_ThreeConsecutiveToolCallResults_AllHaveUniqueMessageIdsAsync() + { + // Arrange — Edge case: 3+ consecutive tool call results + const string ThreadId = "thread1"; + const string RunId = "run1"; + + FunctionResultContent result1 = new("call_1", "Result 1"); + FunctionResultContent result2 = new("call_2", "Result 2"); + FunctionResultContent result3 = new("call_3", "Result 3"); + + List updates = + [ + new ChatResponseUpdate(ChatRole.Tool, [result1, result2, result3]) { MessageId = "msg1" } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + List toolCallResultEvents = events.OfType().ToList(); + Assert.Equal(3, toolCallResultEvents.Count); + + // All messageIds must be distinct + HashSet uniqueMessageIds = new(toolCallResultEvents.Select(e => e.MessageId)); + Assert.Equal(toolCallResultEvents.Count, uniqueMessageIds.Count); + + // All must be non-null/non-empty + Assert.All(toolCallResultEvents, e => + { + Assert.NotNull(e.MessageId); + Assert.NotEmpty(e.MessageId!); + }); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_SingleToolCallResult_HasValidMessageIdAsync() + { + // Arrange — Single tool call result should still get a valid messageId + const string ThreadId = "thread1"; + const string RunId = "run1"; + + FunctionResultContent result = new("call_1", "Result 1"); + List updates = + [ + new ChatResponseUpdate(ChatRole.Tool, [result]) { MessageId = "msg1" } + ]; + + // Act + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + ToolCallResultEvent? resultEvent = events.OfType().Single(); + Assert.NotNull(resultEvent.MessageId); + Assert.NotEmpty(resultEvent.MessageId); + } }