mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
0099a6e2fa
commit
de6d0267f2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user