diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index f5fb103bd4..b11475ddfc 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -341,8 +341,17 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync( }; string? currentMessageId = null; + string? streamingMessageId = null; await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { + // Generate a fallback MessageId when the provider doesn't supply one. + // 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; + } + if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent && !string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal)) diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs new file mode 100644 index 0000000000..5c55408ff8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.AGUI.Shared; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AGUI.UnitTests; + +/// +/// Tests for AGUI streaming behavior when MessageId is null or missing from +/// ChatResponseUpdate objects (e.g., providers like Google GenAI/Vertex AI +/// that don't supply MessageId on streaming chunks). +/// +public sealed class AGUIStreamingMessageIdTests +{ + /// + /// When ChatResponseUpdate objects with null MessageId are fed directly to + /// AsAGUIEventStreamAsync, the AGUI layer generates a fallback MessageId so + /// that events are valid regardless of agent type or provider. + /// + [Fact] + public async Task TextStreaming_NullMessageId_GeneratesFallbackInAGUILayerAsync() + { + // Arrange - Simulate a provider that does NOT set MessageId + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Hello"), + new ChatResponseUpdate(ChatRole.Assistant, " world"), + new ChatResponseUpdate(ChatRole.Assistant, "!") + ]; + + // Act + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert - AGUI layer should generate a fallback MessageId + List startEvents = aguiEvents.OfType().ToList(); + List contentEvents = aguiEvents.OfType().ToList(); + + Assert.Single(startEvents); + Assert.False(string.IsNullOrEmpty(startEvents[0].MessageId)); + + Assert.Equal(3, contentEvents.Count); + Assert.All(contentEvents, e => Assert.False(string.IsNullOrEmpty(e.MessageId))); + + // All events should share the same generated MessageId + string?[] distinctIds = contentEvents.Select(e => e.MessageId).Distinct().ToArray(); + Assert.Single(distinctIds); + Assert.Equal(startEvents[0].MessageId, distinctIds[0]); + } + + /// + /// Full pipeline: ChatClientAgent → AsChatResponseUpdatesAsync → AsAGUIEventStreamAsync + /// with a provider that returns null MessageId. Verifies that fallback MessageId + /// generation ensures valid AGUI events. + /// + [Fact] + public async Task FullPipeline_NullProviderMessageId_ProducesValidAGUIEventsAsync() + { + // Arrange - ChatClientAgent with a mock client that omits MessageId + IChatClient mockChatClient = new NullMessageIdChatClient(); + ChatClientAgent agent = new(mockChatClient, name: "test-agent"); + + ChatMessage userMessage = new(ChatRole.User, "tell me about agents"); + + // Act - Run the full pipeline exactly as MapAGUI does + List aguiEvents = []; + await foreach (BaseEvent evt in agent + .RunStreamingAsync([userMessage]) + .AsChatResponseUpdatesAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert — The pipeline should produce AGUI events with valid messageId + List startEvents = aguiEvents.OfType().ToList(); + List contentEvents = aguiEvents.OfType().ToList(); + + Assert.NotEmpty(startEvents); + Assert.NotEmpty(contentEvents); + + foreach (TextMessageStartEvent startEvent in startEvents) + { + Assert.False( + string.IsNullOrEmpty(startEvent.MessageId), + "TextMessageStartEvent.MessageId should not be null/empty when provider omits it"); + } + + foreach (TextMessageContentEvent contentEvent in contentEvents) + { + Assert.False( + string.IsNullOrEmpty(contentEvent.MessageId), + "TextMessageContentEvent.MessageId should not be null/empty when provider omits it"); + } + + // All content events should share the same messageId + string?[] distinctMessageIds = contentEvents.Select(e => e.MessageId).Distinct().ToArray(); + Assert.Single(distinctMessageIds); + } + + /// + /// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates + /// a fallback so ToolCallStartEvent.ParentMessageId is valid. + /// + [Fact] + public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync() + { + // Arrange - ChatResponseUpdate with a tool call but empty MessageId + FunctionCallContent functionCall = new("call_abc123", "GetWeather") + { + Arguments = new Dictionary { ["location"] = "San Francisco" } + }; + + List providerUpdates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + MessageId = "", + Contents = [functionCall] + } + ]; + + // Act + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert — ParentMessageId should have a generated fallback + ToolCallStartEvent? toolCallStart = aguiEvents.OfType().FirstOrDefault(); + Assert.NotNull(toolCallStart); + Assert.Equal("call_abc123", toolCallStart.ToolCallId); + Assert.Equal("GetWeather", toolCallStart.ToolCallName); + Assert.False( + string.IsNullOrEmpty(toolCallStart.ParentMessageId), + "ParentMessageId should have a generated fallback for empty provider MessageId"); + } + + /// + /// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline + /// produces valid events with correct messageId values. + /// + [Fact] + public async Task TextStreaming_WithProviderMessageId_ProducesValidAGUIEventsAsync() + { + // Arrange — Provider that properly sets MessageId + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Hello") + { + MessageId = "chatcmpl-abc123" + }, + new ChatResponseUpdate(ChatRole.Assistant, " world") + { + MessageId = "chatcmpl-abc123" + } + ]; + + // Act + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert + List startEvents = aguiEvents.OfType().ToList(); + List contentEvents = aguiEvents.OfType().ToList(); + + Assert.Single(startEvents); + Assert.Equal("chatcmpl-abc123", startEvents[0].MessageId); + + Assert.Equal(2, contentEvents.Count); + Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId)); + } +} + +/// +/// Mock IChatClient that simulates a provider not setting MessageId on streaming chunks +/// (e.g., Google GenAI / Vertex AI). +/// +internal sealed class NullMessageIdChatClient : IChatClient +{ + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "response")])); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (string chunk in (string[])["Agents", " are", " autonomous", " programs."]) + { + yield return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(chunk)] + }; + + await Task.Yield(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs index 89cff04de8..5cf0b75eef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs @@ -384,6 +384,45 @@ public void AsChatResponseUpdate_WithRawRepresentationAsChatResponseUpdate_Retur Assert.Same(originalChatResponseUpdate, result); } + [Fact] + public void AsChatResponseUpdate_WithRawRepresentationNullMessageId_ReturnsRawDirectly() + { + // Arrange - RawRepresentation has null MessageId + ChatResponseUpdate originalChatResponseUpdate = new() + { + ResponseId = "original-update", + Contents = [new TextContent("Hello")] + }; + AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); + + // Act + ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); + + // Assert - Returns the raw representation directly without mutation + Assert.Same(originalChatResponseUpdate, result); + Assert.Null(result.MessageId); + } + + [Fact] + public void AsChatResponseUpdate_WithRawRepresentationExistingMessageId_PreservesOriginal() + { + // Arrange - RawRepresentation already has MessageId set by provider + ChatResponseUpdate originalChatResponseUpdate = new() + { + ResponseId = "original-update", + MessageId = "provider-message-id", + Contents = [new TextContent("Hello")] + }; + AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); + + // Act + ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); + + // Assert - Provider's original MessageId should be preserved + Assert.Same(originalChatResponseUpdate, result); + Assert.Equal("provider-message-id", result.MessageId); + } + [Fact] public void AsChatResponseUpdate_WithoutRawRepresentation_CreatesNewChatResponseUpdate() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 7241ca763e..42b115c8bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -1793,6 +1793,75 @@ public async Task VerifyChatClientAgentStreamingAsync() Times.Once); } + /// + /// Verify that RunStreamingAsync passes through null MessageId from provider without modification. + /// MessageId generation is handled by downstream consumers (e.g., AGUI layer), not ChatClientAgent. + /// + [Fact] + public async Task RunStreamingAsync_WithNullMessageId_PassesThroughNullAsync() + { + // Arrange - Provider returns updates WITHOUT MessageId + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"), + new ChatResponseUpdate(role: ChatRole.Assistant, content: " world"), + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(ToAsyncEnumerableAsync(returnUpdates)); + + ChatClientAgent agent = new(mockService.Object); + + // Act + List result = []; + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + result.Add(update); + } + + // Assert - MessageId should be null (ChatClientAgent does not generate fallback IDs) + Assert.Equal(2, result.Count); + Assert.All(result, u => Assert.Null(u.MessageId)); + } + + /// + /// Verify that RunStreamingAsync preserves provider-supplied MessageId when present. + /// + [Fact] + public async Task RunStreamingAsync_WithProviderMessageId_PreservesItAsync() + { + // Arrange - Provider returns updates WITH MessageId (like OpenAI) + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello") { MessageId = "chatcmpl-abc123" }, + new ChatResponseUpdate(role: ChatRole.Assistant, content: " world") { MessageId = "chatcmpl-abc123" }, + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(ToAsyncEnumerableAsync(returnUpdates)); + + ChatClientAgent agent = new(mockService.Object); + + // Act + List result = []; + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + result.Add(update); + } + + // Assert - Provider's MessageId should be preserved, not overwritten + Assert.Equal(2, result.Count); + Assert.All(result, u => Assert.Equal("chatcmpl-abc123", u.MessageId)); + } + /// /// Verify that RunStreamingAsync uses the ChatHistoryProvider factory when the chat client returns no conversation id. ///