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 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync( // 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 @@ chatResponse.Contents[0] is TextContent && _ => 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 async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync "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.