.NET: BugFix #3433 ChatClientAgent streaming responses missing messageid#4615
Conversation
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>
…sponses-missing-messageid
There was a problem hiding this comment.
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
MessageIdgeneration for streaming chunks when providers omit it. - Sync
MessageIdfromAgentResponseUpdateback into aChatResponseUpdateRawRepresentationduring conversion. - Add unit tests for
ChatClientAgent,AgentResponseUpdateconversion, 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. |
| /// <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"); | ||
| } |
There was a problem hiding this comment.
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.
| Assert.Equal(result[0].MessageId, result[1].MessageId); | ||
| } | ||
|
|
||
| /// <summary> |
There was a problem hiding this comment.
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).
| /// <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> |
| string streamingMessageId = $"msg_{Guid.NewGuid():N}"; | ||
|
|
||
| while (hasUpdates) | ||
| { | ||
| var update = responseUpdatesEnumerator.Current; | ||
| if (update is not null) | ||
| { | ||
| update.AuthorName ??= this.Name; | ||
| update.MessageId ??= streamingMessageId; |
There was a problem hiding this comment.
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).
| /// <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()); | ||
| } |
There was a problem hiding this comment.
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.
Motivation and Context