.NET: fix: avoid AGUI tool result message id collisions (#5800)

* fix: avoid AGUI tool result message id collisions

* fix: split mixed tool result message ids
This commit is contained in:
Yufeng He
2026-05-16 05:52:25 +08:00
committed by GitHub
Unverified
parent da308f5f1e
commit a60e541c9a
2 changed files with 103 additions and 2 deletions
@@ -458,8 +458,9 @@ internal static class ChatResponseUpdateAGUIExtensions
// 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;
chatResponse.MessageId = ContainsToolResult(chatResponse)
? Guid.NewGuid().ToString("N")
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
}
if (chatResponse is { Contents.Count: > 0 } &&
@@ -725,4 +726,17 @@ 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;
}
}
@@ -149,6 +149,93 @@ public sealed class AGUIStreamingMessageIdTests
"ParentMessageId should have a generated fallback for empty provider MessageId");
}
/// <summary>
/// Tool results are separate tool-role messages, so their fallback IDs must not
/// collide with the assistant message that requested the tool call.
/// </summary>
[Fact]
public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync()
{
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
{
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
};
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [functionCall]
},
new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")])
];
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
}
[Fact]
public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync()
{
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
{
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
};
List<ChatResponseUpdate> providerUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [functionCall]
},
new ChatResponseUpdate
{
Role = ChatRole.Tool,
Contents =
[
new TextContent("Tool says: "),
new FunctionResultContent("call_abc123", "72F and sunny")
]
}
];
List<BaseEvent> aguiEvents = [];
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
{
aguiEvents.Add(evt);
}
TextMessageStartEvent[] textStarts = aguiEvents.OfType<TextMessageStartEvent>().ToArray();
TextMessageContentEvent toolText = Assert.Single(
aguiEvents.OfType<TextMessageContentEvent>(),
content => content.Delta == "Tool says: ");
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
Assert.Equal(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);
}
/// <summary>
/// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline
/// produces valid events with correct messageId values.