Skip to content

Commit a60e541

Browse files
authored
.NET: fix: avoid AGUI tool result message id collisions (microsoft#5800)
* fix: avoid AGUI tool result message id collisions * fix: split mixed tool result message ids
1 parent da308f5 commit a60e541

2 files changed

Lines changed: 103 additions & 2 deletions

File tree

dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,9 @@ public static async IAsyncEnumerable<BaseEvent> AsAGUIEventStreamAsync(
458458
// This ensures all AGUI events have a valid messageId regardless of agent type.
459459
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
460460
{
461-
streamingMessageId ??= Guid.NewGuid().ToString("N");
462-
chatResponse.MessageId = streamingMessageId;
461+
chatResponse.MessageId = ContainsToolResult(chatResponse)
462+
? Guid.NewGuid().ToString("N")
463+
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
463464
}
464465

465466
if (chatResponse is { Contents.Count: > 0 } &&
@@ -725,4 +726,17 @@ chatResponse.Contents[0] is TextContent &&
725726
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
726727
};
727728
}
729+
730+
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
731+
{
732+
foreach (AIContent content in chatResponse.Contents)
733+
{
734+
if (content is FunctionResultContent)
735+
{
736+
return true;
737+
}
738+
}
739+
740+
return false;
741+
}
728742
}

dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,93 @@ public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync
149149
"ParentMessageId should have a generated fallback for empty provider MessageId");
150150
}
151151

152+
/// <summary>
153+
/// Tool results are separate tool-role messages, so their fallback IDs must not
154+
/// collide with the assistant message that requested the tool call.
155+
/// </summary>
156+
[Fact]
157+
public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync()
158+
{
159+
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
160+
{
161+
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
162+
};
163+
164+
List<ChatResponseUpdate> providerUpdates =
165+
[
166+
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
167+
new ChatResponseUpdate
168+
{
169+
Role = ChatRole.Assistant,
170+
Contents = [functionCall]
171+
},
172+
new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")])
173+
];
174+
175+
List<BaseEvent> aguiEvents = [];
176+
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
177+
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
178+
{
179+
aguiEvents.Add(evt);
180+
}
181+
182+
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
183+
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
184+
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
185+
186+
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
187+
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
188+
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
189+
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
190+
}
191+
192+
[Fact]
193+
public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync()
194+
{
195+
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
196+
{
197+
Arguments = new Dictionary<string, object?> { ["location"] = "San Francisco" }
198+
};
199+
200+
List<ChatResponseUpdate> providerUpdates =
201+
[
202+
new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"),
203+
new ChatResponseUpdate
204+
{
205+
Role = ChatRole.Assistant,
206+
Contents = [functionCall]
207+
},
208+
new ChatResponseUpdate
209+
{
210+
Role = ChatRole.Tool,
211+
Contents =
212+
[
213+
new TextContent("Tool says: "),
214+
new FunctionResultContent("call_abc123", "72F and sunny")
215+
]
216+
}
217+
];
218+
219+
List<BaseEvent> aguiEvents = [];
220+
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
221+
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
222+
{
223+
aguiEvents.Add(evt);
224+
}
225+
226+
TextMessageStartEvent[] textStarts = aguiEvents.OfType<TextMessageStartEvent>().ToArray();
227+
TextMessageContentEvent toolText = Assert.Single(
228+
aguiEvents.OfType<TextMessageContentEvent>(),
229+
content => content.Delta == "Tool says: ");
230+
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
231+
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
232+
233+
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
234+
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
235+
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
236+
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
237+
}
238+
152239
/// <summary>
153240
/// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline
154241
/// produces valid events with correct messageId values.

0 commit comments

Comments
 (0)