Skip to content

Commit 35b0c86

Browse files
PederHPstephentoub
andauthored
Fix FunctionApprovalResponseContent to message mapping (#7152)
Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent 2871008 commit 35b0c86

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,10 +1448,23 @@ private static (List<ApprovalResultWithRequestMessage>? approvals, List<Approval
14481448
// The majority of the time, all FCC would be part of a single message, so no need to create a dictionary for this case.
14491449
// If we are dealing with multiple messages though, we need to keep track of them by their message ID.
14501450
messagesById = [];
1451-
messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage;
1451+
1452+
// Use the effective key for the previous message, accounting for fallbackMessageId substitution.
1453+
// If the message's MessageId was set to fallbackMessageId (because the original RequestMessage.MessageId was null),
1454+
// we should use empty string as the key to match the lookup key used elsewhere.
1455+
var previousMessageKey = currentMessage.MessageId == fallbackMessageId
1456+
? string.Empty
1457+
: (currentMessage.MessageId ?? string.Empty);
1458+
messagesById[previousMessageKey] = currentMessage;
14521459
}
14531460

1454-
_ = messagesById?.TryGetValue(resultWithRequestMessage.RequestMessage?.MessageId ?? string.Empty, out currentMessage);
1461+
// Use RequestMessage.MessageId for the lookup key, since that's the original message ID from the provider.
1462+
// We must use the same key for both lookup and storage to ensure proper grouping.
1463+
// Note: currentMessage.MessageId may differ from RequestMessage.MessageId because
1464+
// ConvertToFunctionCallContentMessage sets a fallbackMessageId when RequestMessage.MessageId is null.
1465+
var messageKey = resultWithRequestMessage.RequestMessage?.MessageId ?? string.Empty;
1466+
1467+
_ = messagesById?.TryGetValue(messageKey, out currentMessage);
14551468

14561469
if (currentMessage is null)
14571470
{
@@ -1463,7 +1476,7 @@ private static (List<ApprovalResultWithRequestMessage>? approvals, List<Approval
14631476
}
14641477

14651478
#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499)
1466-
messagesById?[currentMessage.MessageId ?? string.Empty] = currentMessage;
1479+
messagesById?[messageKey] = currentMessage;
14671480
#pragma warning restore IDE0058
14681481
}
14691482

test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,59 @@ public async Task ApprovedApprovalResponsesAreExecutedAsync()
186186
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
187187
}
188188

189+
[Fact]
190+
public async Task ApprovedApprovalResponsesAreGroupedWhenMessageIdIsNullAsync()
191+
{
192+
var options = new ChatOptions
193+
{
194+
Tools =
195+
[
196+
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
197+
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
198+
]
199+
};
200+
201+
// Key difference from other tests: MessageId is NOT set on the assistant message
202+
List<ChatMessage> input =
203+
[
204+
new ChatMessage(ChatRole.User, "hello"),
205+
new ChatMessage(ChatRole.Assistant,
206+
[
207+
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
208+
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
209+
]), // Note: No MessageId set - this is the bug trigger
210+
new ChatMessage(ChatRole.User,
211+
[
212+
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
213+
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
214+
]),
215+
];
216+
217+
// Both FCCs should be in a SINGLE assistant message, not split across multiple messages
218+
List<ChatMessage> expectedDownstreamClientInput =
219+
[
220+
new ChatMessage(ChatRole.User, "hello"),
221+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
222+
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
223+
];
224+
225+
List<ChatMessage> downstreamClientOutput =
226+
[
227+
new ChatMessage(ChatRole.Assistant, "world"),
228+
];
229+
230+
List<ChatMessage> output =
231+
[
232+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
233+
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
234+
new ChatMessage(ChatRole.Assistant, "world"),
235+
];
236+
237+
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
238+
239+
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
240+
}
241+
189242
[Fact]
190243
public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync()
191244
{

0 commit comments

Comments
 (0)