Skip to content

.NET: BugFix #3433 ChatClientAgent streaming responses missing messageid#4615

Open
rogerbarreto wants to merge 3 commits intomicrosoft:mainfrom
rogerbarreto:issues/3433-bug-chatclientagent-streaming-responses-missing-messageid
Open

.NET: BugFix #3433 ChatClientAgent streaming responses missing messageid#4615
rogerbarreto wants to merge 3 commits intomicrosoft:mainfrom
rogerbarreto:issues/3433-bug-chatclientagent-streaming-responses-missing-messageid

Conversation

@rogerbarreto
Copy link
Member

rogerbarreto and others added 2 commits March 9, 2026 13:50
Generate fallback MessageId in ChatClientAgent.RunCoreStreamingAsync when
the underlying LLM provider does not set ChatResponseUpdate.MessageId.
Without a MessageId the AGUI converter's null==null check silently drops
all text content, causing CopilotKit Zod validation errors.

Changes:
- ChatClientAgent: generate msg_{Guid} fallback via ??= in streaming loop
- AgentResponseExtensions: sync wrapper MessageId back to RawRepresentation
  in AsChatResponseUpdate() so downstream consumers see the value
- Add unit tests for both fixes and AGUI streaming MessageId scenarios

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@rogerbarreto rogerbarreto self-assigned this Mar 11, 2026
Copilot AI review requested due to automatic review settings March 11, 2026 09:36
@github-actions github-actions bot changed the title .Net: BugFix #3433 ChatClientAgent streaming responses missing messageid .NET: BugFix #3433 ChatClientAgent streaming responses missing messageid Mar 11, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes .NET streaming MessageId propagation for ChatClientAgent so AGUI/CopilotKit event conversion doesn’t emit messageId: null (Issue #3433).

Changes:

  • Add fallback MessageId generation for streaming chunks when providers omit it.
  • Sync MessageId from AgentResponseUpdate back into a ChatResponseUpdate RawRepresentation during conversion.
  • Add unit tests for ChatClientAgent, AgentResponseUpdate conversion, and an AGUI pipeline coverage test.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs Adds per-stream fallback MessageId assignment for streaming updates.
dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponseExtensions.cs Ensures AsChatResponseUpdate() can recover MessageId into RawRepresentation when missing.
dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs Adds tests for fallback MessageId generation and preserving provider IDs.
dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs Adds tests verifying MessageId syncing behavior in AsChatResponseUpdate().
dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs Adds an end-to-end pipeline test verifying AGUI events include non-null messageId.

Comment on lines +105 to +145
/// <summary>
/// When ChatResponseUpdate has empty string MessageId, ToolCallStartEvent.ParentMessageId
/// is empty. The ??= fallback only handles null, not empty string — providers returning
/// "" should ideally return null instead.
/// </summary>
[Fact(Skip = "Known edge case: empty string MessageId from provider - ??= only handles null")]
public async Task ToolCalls_EmptyMessageId_ProducesInvalidParentMessageIdAsync()
{
// 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 — ToolCallStartEvent should have a valid parentMessageId
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),
"ToolCallStartEvent.ParentMessageId should not be empty");
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skipped test ToolCalls_EmptyMessageId_ProducesInvalidParentMessageIdAsync asserts ParentMessageId is non-empty, but the test setup sets ChatResponseUpdate.MessageId to "" and the converter assigns ParentMessageId = chatResponse.MessageId, so the expected documented outcome is an empty ParentMessageId. Adjust the assertion (and/or the test name/comment) so it matches the behavior being documented.

Copilot uses AI. Check for mistakes.
Assert.Equal(result[0].MessageId, result[1].MessageId);
}

/// <summary>
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current test coverage validates (a) provider never sets MessageId and (b) provider sets MessageId on every chunk, but it doesn’t cover the common streaming pattern where only the first chunk has a provider MessageId and subsequent chunks omit it. Adding a test for that case will guard against mid-stream MessageId changes that would split messages downstream (e.g., in AGUI event generation).

Suggested change
/// <summary>
/// <summary>
/// Verify that RunStreamingAsync preserves provider-supplied MessageId when only the first chunk sets it.
/// This matches common provider behavior where the initial streaming update includes an id and
/// subsequent chunks omit it. All updates in the same stream should share the initial provider id.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithProviderMessageIdOnFirstChunk_PreservesItAcrossStreamAsync()
{
// Arrange - Provider sets MessageId on the first update only
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello") { MessageId = "chatcmpl-abc123" },
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 - Provider's MessageId from the first chunk should be preserved across the stream
Assert.Equal(2, result.Count);
Assert.All(result, u => Assert.False(string.IsNullOrEmpty(u.MessageId), "MessageId should not be null/empty"));
Assert.All(result, u => Assert.Equal("chatcmpl-abc123", u.MessageId));
}
/// <summary>

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +346
string streamingMessageId = $"msg_{Guid.NewGuid():N}";

while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
update.MessageId ??= streamingMessageId;
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatClientAgent assigns a generated fallback MessageId to any update with null MessageId, but it doesn’t propagate a provider-supplied MessageId to subsequent chunks if the provider only sets it on the first chunk. In that case later chunks will get a different fallback id, causing message splitting in downstream grouping/AGUI event generation. Consider seeding the stream’s MessageId from the first non-null provider MessageId (or generating one if none is ever provided) and then applying that same value to every chunk (optionally normalizing/overwriting later mismatched ids for consistency).

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +53
/// <summary>
/// When ChatResponseUpdate objects with null MessageId are fed directly to
/// AsAGUIEventStreamAsync (bypassing ChatClientAgent), the message-start check
/// (null == null) prevents TextMessageStartEvent from being emitted, silently
/// dropping text content.
///
/// This scenario doesn't occur in the production pipeline because ChatClientAgent
/// generates a fallback MessageId. This test documents the AGUI converter's
/// behavior for consumers using it directly.
/// </summary>
[Fact(Skip = "Known edge case: direct AGUI converter usage without ChatClientAgent - not in production pipeline")]
public async Task TextStreaming_NullMessageId_DropsContentAsync()
{
// 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 - null == null in the message-start check means no start/content events
Assert.NotEmpty(aguiEvents.OfType<TextMessageStartEvent>().ToList());
Assert.NotEmpty(aguiEvents.OfType<TextMessageContentEvent>().ToList());
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skipped test TextStreaming_NullMessageId_DropsContentAsync has assertions that contradict both its doc comment and the current AsAGUIEventStreamAsync logic: with MessageId == null, no TextMessageStartEvent is emitted (null == null), but TextMessageContentEvent is still emitted (with a null MessageId). Update the test name/comments and assertions to reflect the actual behavior you’re documenting (e.g., assert start events are empty and content events have null MessageId), or remove the test if it’s not meaningful.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Bug]: ChatClientAgent streaming responses missing MessageId causing AGUI/CopilotKit validation failures

3 participants