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 @@ internal static class ChatResponseUpdateAGUIExtensions
};
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 class AgentResponseUpdateExtensionsTests
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 partial class ChatClientAgentTests
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.
///