From a60e541c9ac53e9cd944986cafd5b044d8d004d0 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 16 May 2026 05:52:25 +0800 Subject: [PATCH] .NET: fix: avoid AGUI tool result message id collisions (#5800) * fix: avoid AGUI tool result message id collisions * fix: split mixed tool result message ids --- .../ChatResponseUpdateAGUIExtensions.cs | 18 +++- .../AGUIStreamingMessageIdTests.cs | 87 +++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index ad8435842b..144a560f7f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -458,8 +458,9 @@ internal static class ChatResponseUpdateAGUIExtensions // This ensures all AGUI events have a valid messageId regardless of agent type. if (string.IsNullOrWhiteSpace(chatResponse.MessageId)) { - streamingMessageId ??= Guid.NewGuid().ToString("N"); - chatResponse.MessageId = streamingMessageId; + chatResponse.MessageId = ContainsToolResult(chatResponse) + ? Guid.NewGuid().ToString("N") + : (streamingMessageId ??= Guid.NewGuid().ToString("N")); } if (chatResponse is { Contents.Count: > 0 } && @@ -725,4 +726,17 @@ internal static class ChatResponseUpdateAGUIExtensions _ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())), }; } + + private static bool ContainsToolResult(ChatResponseUpdate chatResponse) + { + foreach (AIContent content in chatResponse.Contents) + { + if (content is FunctionResultContent) + { + return true; + } + } + + return false; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs index 5c55408ff8..502e23d81c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs @@ -149,6 +149,93 @@ public sealed class AGUIStreamingMessageIdTests "ParentMessageId should have a generated fallback for empty provider MessageId"); } + /// + /// Tool results are separate tool-role messages, so their fallback IDs must not + /// collide with the assistant message that requested the tool call. + /// + [Fact] + public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync() + { + FunctionCallContent functionCall = new("call_abc123", "GetWeather") + { + Arguments = new Dictionary { ["location"] = "San Francisco" } + }; + + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"), + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [functionCall] + }, + new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")]) + ]; + + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType()); + ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType()); + ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType()); + + Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId); + Assert.Equal("call_abc123", toolCallResult.ToolCallId); + Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId)); + Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId); + } + + [Fact] + public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync() + { + FunctionCallContent functionCall = new("call_abc123", "GetWeather") + { + Arguments = new Dictionary { ["location"] = "San Francisco" } + }; + + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"), + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [functionCall] + }, + new ChatResponseUpdate + { + Role = ChatRole.Tool, + Contents = + [ + new TextContent("Tool says: "), + new FunctionResultContent("call_abc123", "72F and sunny") + ] + } + ]; + + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + TextMessageStartEvent[] textStarts = aguiEvents.OfType().ToArray(); + TextMessageContentEvent toolText = Assert.Single( + aguiEvents.OfType(), + content => content.Delta == "Tool says: "); + ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType()); + ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType()); + + Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId); + Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId); + Assert.Equal(toolCallResult.MessageId, toolText.MessageId); + Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId); + } + /// /// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline /// produces valid events with correct messageId values.