.NET: fix parallel tool call rendering in AGUI translation layer (#6009)

Fix three interlocked bugs that prevent parallel tool calls from rendering
correctly in AG-UI protocol clients:

Bug #1: Scope synthetic MessageId fallback to text events only. The shared
streamingMessageId was leaking into ToolCallStartEvent.ParentMessageId,
causing all parallel tool calls to collapse into one FE card.

Bug #2: Make ToolCallResultEvent.MessageId deterministically unique using
result-{CallId} format. MEAI's FunctionInvokingChatClient batches all
results with a shared MessageId, collapsing them in FE reconciliation.

Bug #3: Coalesce consecutive assistant-tool-call messages in AsChatMessages.
Once Bug #1 is fixed, the FE produces separate AGUIAssistantMessage per
tool call. On multi-turn replay these become consecutive assistant messages
without intervening tool results, triggering HTTP 400 from Azure OpenAI.

Remove the now-dead ContainsToolResult helper introduced by PR #5800.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2026-05-25 10:31:29 +01:00
committed by GitHub
Unverified
parent 0099a6e2fa
commit de6d0267f2
4 changed files with 299 additions and 43 deletions
@@ -20,8 +20,28 @@ internal static class AGUIChatMessageExtensions
this IEnumerable<AGUIMessage> aguiMessages,
JsonSerializerOptions jsonSerializerOptions)
{
// Coalesce consecutive AGUIAssistantMessages that carry tool_calls into a single
// ChatMessage. The AG-UI client (e.g. @ag-ui/client) creates a separate assistant
// message per tool call when ToolCallStartEvent.parentMessageId is empty, but
// OpenAI's chat-completion API requires every assistant message with tool_calls
// to be IMMEDIATELY followed by tool responses for each of its tool_call_ids.
// Sending two consecutive single-tool-call assistant messages before any tool
// result triggers HTTP 400 "tool_call_ids did not have response messages".
List<AIContent>? pendingContents = null;
string? pendingId = null;
foreach (var message in aguiMessages)
{
bool isAssistantWithToolCalls =
message is AGUIAssistantMessage am && am.ToolCalls is { Length: > 0 };
if (pendingContents is not null && !isAssistantWithToolCalls)
{
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
pendingContents = null;
pendingId = null;
}
var role = MapChatRole(message.Role);
switch (message)
@@ -84,14 +104,14 @@ internal static class AGUIChatMessageExtensions
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
{
var contents = new List<AIContent>();
pendingContents ??= new List<AIContent>();
pendingId ??= message.Id;
if (!string.IsNullOrEmpty(assistantMessage.Content))
{
contents.Add(new TextContent(assistantMessage.Content));
pendingContents.Add(new TextContent(assistantMessage.Content));
}
// Add tool calls
foreach (var toolCall in assistantMessage.ToolCalls)
{
Dictionary<string, object?>? arguments = null;
@@ -102,16 +122,12 @@ internal static class AGUIChatMessageExtensions
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
}
contents.Add(new FunctionCallContent(
pendingContents.Add(new FunctionCallContent(
toolCall.Id,
toolCall.Function.Name,
arguments));
}
yield return new ChatMessage(role, contents)
{
MessageId = message.Id
};
break;
}
@@ -134,6 +150,12 @@ internal static class AGUIChatMessageExtensions
}
}
}
// Flush remaining pending assistant-tool-call entry at end of stream.
if (pendingContents is not null)
{
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
}
}
public static IEnumerable<AGUIMessage> AsAGUIMessages(
@@ -448,24 +448,36 @@ internal static class ChatResponseUpdateAGUIExtensions
};
string? currentMessageId = null;
string? streamingMessageId = null;
string? textStreamingFallback = null;
bool textInFallback = false;
string? currentReasoningBaseId = null;
string? currentReasoningId = null;
string? currentReasoningMessageId = 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))
// The text-event surface (TextMessageStart/Content/End) requires a non-empty
// MessageId to be valid AGUI. Generate a fallback scoped to a contiguous run of
// null/empty-MessageId chunks (one logical text message). Leave the raw
// chatResponse.MessageId untouched so the tool-call surface below uses the raw
// provider value — collapsing parallel tool calls under a synthetic shared parent
// would make the FE render them as one assistant-message bubble instead of
// distinct rows.
string? textMessageId = chatResponse.MessageId;
if (string.IsNullOrWhiteSpace(textMessageId))
{
chatResponse.MessageId = ContainsToolResult(chatResponse)
? Guid.NewGuid().ToString("N")
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
textStreamingFallback ??= Guid.NewGuid().ToString("N");
textMessageId = textStreamingFallback;
textInFallback = true;
}
else if (textInFallback)
{
textStreamingFallback = null;
textInFallback = false;
}
if (chatResponse is { Contents.Count: > 0 } &&
chatResponse.Contents[0] is TextContent &&
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
!string.Equals(currentMessageId, textMessageId, StringComparison.Ordinal))
{
// Close any open reasoning block before opening a text message, so AG-UI
// events are properly bracketed. MEAI providers share one MessageId across
@@ -498,11 +510,11 @@ internal static class ChatResponseUpdateAGUIExtensions
// Start the new message
yield return new TextMessageStartEvent
{
MessageId = chatResponse.MessageId!,
MessageId = textMessageId!,
Role = chatResponse.Role!.Value.Value
};
currentMessageId = chatResponse.MessageId;
currentMessageId = textMessageId;
}
// Emit text content if present
@@ -577,9 +589,15 @@ internal static class ChatResponseUpdateAGUIExtensions
currentReasoningMessageId = null;
}
// Each tool result is a distinct tool-role message on the AGUI wire.
// MEAI's FunctionInvokingChatClient shares one synthetic MessageId
// across all FunctionResultContent items, but the FE keys messages
// by id, so emitting them with the same id collapses them in React
// reconciliation. Derive a unique, deterministic per-result id from
// the (LLM-assigned) call id.
yield return new ToolCallResultEvent
{
MessageId = chatResponse.MessageId,
MessageId = $"result-{functionResultContent.CallId}",
ToolCallId = functionResultContent.CallId,
Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "",
Role = AGUIRoles.Tool
@@ -674,7 +692,7 @@ internal static class ChatResponseUpdateAGUIExtensions
// Text content event
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
MessageId = textMessageId!,
#if !NET
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
#else
@@ -726,17 +744,4 @@ internal static class ChatResponseUpdateAGUIExtensions
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
};
}
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
{
foreach (AIContent content in chatResponse.Contents)
{
if (content is FunctionResultContent)
{
return true;
}
}
return false;
}
}
@@ -914,4 +914,147 @@ public sealed class AGUIChatMessageExtensionsTests
}
#endregion
#region Consecutive Assistant-Tool-Call Coalescing
/// <summary>
/// Bug #3 reproduction: consecutive AGUIAssistantMessages with ToolCalls should
/// be coalesced into a single ChatMessage with multiple FunctionCallContent
/// entries. Without coalescing, Azure OpenAI rejects the history with HTTP 400.
/// </summary>
[Fact]
public void AsChatMessages_ConsecutiveAssistantToolCallMessages_CoalesceIntoOneChatMessage()
{
// Arrange — 3 consecutive assistant messages with tool calls (no intervening tool msg)
List<AGUIMessage> aguiMessages =
[
new AGUIUserMessage { Id = "user-1", Content = "Run 3 queries" },
new AGUIAssistantMessage
{
Id = "asst-1",
Content = "",
ToolCalls =
[
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"1\"}" } }
]
},
new AGUIAssistantMessage
{
Id = "asst-2",
Content = "",
ToolCalls =
[
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"2\"}" } }
]
},
new AGUIAssistantMessage
{
Id = "asst-3",
Content = "",
ToolCalls =
[
new AGUIToolCall { Id = "call_C", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"3\"}" } }
]
},
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"result1\"" },
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"result2\"" },
new AGUIToolMessage { Id = "tool-3", ToolCallId = "call_C", Content = "\"result3\"" },
new AGUIUserMessage { Id = "user-2", Content = "Run it again" },
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert — the 3 consecutive assistant-tool-call messages should coalesce into 1
List<ChatMessage> assistantWithToolCalls = chatMessages
.Where(m => m.Role == ChatRole.Assistant && m.Contents.OfType<FunctionCallContent>().Any())
.ToList();
Assert.Single(assistantWithToolCalls);
// The single coalesced message should contain all 3 FunctionCallContent entries
List<FunctionCallContent> functionCalls = assistantWithToolCalls[0].Contents
.OfType<FunctionCallContent>().ToList();
Assert.Equal(3, functionCalls.Count);
Assert.Equal("call_A", functionCalls[0].CallId);
Assert.Equal("call_B", functionCalls[1].CallId);
Assert.Equal("call_C", functionCalls[2].CallId);
// MessageId should be from the first message in the coalesced group
Assert.Equal("asst-1", assistantWithToolCalls[0].MessageId);
// Total messages: user + coalesced assistant + 3 tools + user = 6
Assert.Equal(6, chatMessages.Count);
}
/// <summary>
/// A single assistant message with tool calls (not consecutive) should still
/// produce one ChatMessage — no behavior change from coalescing logic.
/// </summary>
[Fact]
public void AsChatMessages_SingleAssistantToolCallMessage_ProducesOneChatMessage()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIAssistantMessage
{
Id = "asst-1",
Content = "Here are the results",
ToolCalls =
[
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
]
},
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"r1\"" },
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"r2\"" },
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert — single assistant message, not coalesced from multiple
Assert.Equal(3, chatMessages.Count);
Assert.Equal(ChatRole.Assistant, chatMessages[0].Role);
List<FunctionCallContent> calls = chatMessages[0].Contents.OfType<FunctionCallContent>().ToList();
Assert.Equal(2, calls.Count);
Assert.Equal("asst-1", chatMessages[0].MessageId);
}
/// <summary>
/// When consecutive assistant-tool-call messages are at the END of the stream
/// (no subsequent non-tool-call message to trigger flush), they should still
/// be coalesced and flushed.
/// </summary>
[Fact]
public void AsChatMessages_ConsecutiveAssistantToolCallsAtEndOfStream_FlushesCorrectly()
{
// Arrange — stream ends with consecutive assistant tool-call messages
List<AGUIMessage> aguiMessages =
[
new AGUIUserMessage { Id = "user-1", Content = "Do things" },
new AGUIAssistantMessage
{
Id = "asst-1",
ToolCalls = [new AGUIToolCall { Id = "call_X", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
},
new AGUIAssistantMessage
{
Id = "asst-2",
ToolCalls = [new AGUIToolCall { Id = "call_Y", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
},
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert — should be user + 1 coalesced assistant = 2 messages
Assert.Equal(2, chatMessages.Count);
Assert.Equal(ChatRole.User, chatMessages[0].Role);
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
Assert.Equal(2, chatMessages[1].Contents.OfType<FunctionCallContent>().Count());
}
#endregion
}
@@ -109,11 +109,13 @@ public sealed class AGUIStreamingMessageIdTests
}
/// <summary>
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates
/// a fallback so ToolCallStartEvent.ParentMessageId is valid.
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer passes
/// through the raw provider value for ToolCallStartEvent.ParentMessageId.
/// Tool-call chunks should NOT receive the text-event fallback GUID — that
/// would collapse parallel tool calls into one assistant message in the FE.
/// </summary>
[Fact]
public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync()
public async Task ToolCalls_EmptyMessageId_DoesNotGenerateFallbackParentMessageIdAsync()
{
// Arrange - ChatResponseUpdate with a tool call but empty MessageId
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
@@ -139,14 +141,14 @@ public sealed class AGUIStreamingMessageIdTests
aguiEvents.Add(evt);
}
// Assert — ParentMessageId should have a generated fallback
// Assert — ParentMessageId should be empty (raw provider value, no synthetic fallback)
ToolCallStartEvent? toolCallStart = aguiEvents.OfType<ToolCallStartEvent>().FirstOrDefault();
Assert.NotNull(toolCallStart);
Assert.Equal("call_abc123", toolCallStart.ToolCallId);
Assert.Equal("GetWeather", toolCallStart.ToolCallName);
Assert.False(
Assert.True(
string.IsNullOrEmpty(toolCallStart.ParentMessageId),
"ParentMessageId should have a generated fallback for empty provider MessageId");
"ParentMessageId should be empty when provider omits MessageId (raw pass-through)");
}
/// <summary>
@@ -183,10 +185,13 @@ public sealed class AGUIStreamingMessageIdTests
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
// Tool-call ParentMessageId should NOT leak the text fallback GUID
Assert.NotEqual(textStart.MessageId, toolCallStart.ParentMessageId);
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
// Result MessageId should be deterministic based on CallId
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
}
[Fact]
@@ -230,10 +235,11 @@ public sealed class AGUIStreamingMessageIdTests
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
// Tool-call ParentMessageId should NOT leak the text fallback GUID
Assert.NotEqual(textStarts[0].MessageId, toolCallStart.ParentMessageId);
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
// Result MessageId should be deterministic based on CallId
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
}
/// <summary>
@@ -274,6 +280,86 @@ public sealed class AGUIStreamingMessageIdTests
Assert.Equal(2, contentEvents.Count);
Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId));
}
/// <summary>
/// Bug #1 reproduction: parallel tool calls with empty MessageId should NOT all
/// share the same synthetic ParentMessageId. Each should pass through the raw
/// provider value (empty), allowing the FE to render them as distinct cards.
/// </summary>
[Fact]
public async Task ParallelToolCalls_EmptyMessageId_DoNotShareParentMessageIdAsync()
{
// Arrange — 3 parallel tool calls with empty MessageId (real OpenAI behavior)
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Let me run those queries.") { MessageId = "chatcmpl-real" },
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_A", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "1" } }] },
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_B", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "2" } }] },
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_C", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "3" } }] },
];
// Act
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}
// Assert — all 3 tool calls should have empty ParentMessageId (raw provider value),
// NOT the text fallback GUID
List<ToolCallStartEvent> toolCallStarts = aguiEvents.OfType<ToolCallStartEvent>().ToList();
Assert.Equal(3, toolCallStarts.Count);
Assert.All(toolCallStarts, tc => Assert.True(string.IsNullOrEmpty(tc.ParentMessageId)));
// Text events should still have a valid fallback MessageId
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
Assert.False(string.IsNullOrEmpty(textStart.MessageId));
}
/// <summary>
/// Bug #2 reproduction: tool results batched into one ChatResponseUpdate with a
/// shared MEAI MessageId should each get a unique deterministic MessageId.
/// </summary>
[Fact]
public async Task ToolCallResults_SharedMeaiMessageId_HaveUniqueMessageIdsPerCallAsync()
{
// Arrange — MEAI batches all FunctionResultContent into one update with shared id
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate
{
Role = ChatRole.Tool,
MessageId = "meai-shared-id",
Contents =
[
new FunctionResultContent("call_A", "result1"),
new FunctionResultContent("call_B", "result2"),
new FunctionResultContent("call_C", "result3"),
]
},
];
// Act
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}
// Assert — each result should have a unique MessageId
List<ToolCallResultEvent> toolResults = aguiEvents.OfType<ToolCallResultEvent>().ToList();
Assert.Equal(3, toolResults.Count);
string?[] distinctIds = toolResults.Select(r => r.MessageId).Distinct().ToArray();
Assert.Equal(3, distinctIds.Length);
// Verify deterministic format
Assert.Equal("result-call_A", toolResults[0].MessageId);
Assert.Equal("result-call_B", toolResults[1].MessageId);
Assert.Equal("result-call_C", toolResults[2].MessageId);
}
}
/// <summary>