Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,17 @@ public static async IAsyncEnumerable<BaseEvent> 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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
public sealed class AGUIStreamingMessageIdTests
{
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task TextStreaming_NullMessageId_GeneratesFallbackInAGUILayerAsync()
{
// Arrange - Simulate a provider that does NOT set MessageId
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Hello"),
new ChatResponseUpdate(ChatRole.Assistant, " world"),
new ChatResponseUpdate(ChatRole.Assistant, "!")
];

// Act
List<BaseEvent> 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<TextMessageStartEvent> startEvents = aguiEvents.OfType<TextMessageStartEvent>().ToList();
List<TextMessageContentEvent> contentEvents = aguiEvents.OfType<TextMessageContentEvent>().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]);
}

/// <summary>
/// Full pipeline: ChatClientAgent → AsChatResponseUpdatesAsync → AsAGUIEventStreamAsync
/// with a provider that returns null MessageId. Verifies that fallback MessageId
/// generation ensures valid AGUI events.
/// </summary>
[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<BaseEvent> 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<TextMessageStartEvent> startEvents = aguiEvents.OfType<TextMessageStartEvent>().ToList();
List<TextMessageContentEvent> contentEvents = aguiEvents.OfType<TextMessageContentEvent>().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);
}

/// <summary>
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates
/// a fallback so ToolCallStartEvent.ParentMessageId is valid.
/// </summary>
[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<string, object?> { ["location"] = "San Francisco" }
};

List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate
{
Role = ChatRole.Assistant,
MessageId = "",
Contents = [functionCall]
}
];

// Act
List<BaseEvent> 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<ToolCallStartEvent>().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");
}

/// <summary>
/// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline
/// produces valid events with correct messageId values.
/// </summary>
[Fact]
public async Task TextStreaming_WithProviderMessageId_ProducesValidAGUIEventsAsync()
{
// Arrange — Provider that properly sets MessageId
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Hello")
{
MessageId = "chatcmpl-abc123"
},
new ChatResponseUpdate(ChatRole.Assistant, " world")
{
MessageId = "chatcmpl-abc123"
}
];

// Act
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}

// Assert
List<TextMessageStartEvent> startEvents = aguiEvents.OfType<TextMessageStartEvent>().ToList();
List<TextMessageContentEvent> contentEvents = aguiEvents.OfType<TextMessageContentEvent>().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));
}
}

/// <summary>
/// Mock IChatClient that simulates a provider not setting MessageId on streaming chunks
/// (e.g., Google GenAI / Vertex AI).
/// </summary>
internal sealed class NullMessageIdChatClient : IChatClient
{
public void Dispose()
{
}

public object? GetService(Type serviceType, object? serviceKey = null) => null;

public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "response")]));
}

public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1793,6 +1793,75 @@ public async Task VerifyChatClientAgentStreamingAsync()
Times.Once);
}

/// <summary>
/// Verify that RunStreamingAsync passes through null MessageId from provider without modification.
/// MessageId generation is handled by downstream consumers (e.g., AGUI layer), not ChatClientAgent.
/// </summary>
[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<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(ToAsyncEnumerableAsync(returnUpdates));

ChatClientAgent agent = new(mockService.Object);

// Act
List<AgentResponseUpdate> 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));
}

/// <summary>
/// Verify that RunStreamingAsync preserves provider-supplied MessageId when present.
/// </summary>
[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<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(ToAsyncEnumerableAsync(returnUpdates));

ChatClientAgent agent = new(mockService.Object);

// Act
List<AgentResponseUpdate> 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));
}

/// <summary>
/// Verify that RunStreamingAsync uses the ChatHistoryProvider factory when the chat client returns no conversation id.
/// </summary>
Expand Down
Loading