diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index af8b19c8d84..ffdf33f7645 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -28,8 +28,6 @@ namespace Microsoft.Extensions.AI; // [JsonDerivedType(typeof(FunctionApprovalResponseContent), typeDiscriminator: "functionApprovalResponse")] // [JsonDerivedType(typeof(McpServerToolCallContent), typeDiscriminator: "mcpServerToolCall")] // [JsonDerivedType(typeof(McpServerToolResultContent), typeDiscriminator: "mcpServerToolResult")] -// [JsonDerivedType(typeof(McpServerToolApprovalRequestContent), typeDiscriminator: "mcpServerToolApprovalRequest")] -// [JsonDerivedType(typeof(McpServerToolApprovalResponseContent), typeDiscriminator: "mcpServerToolApprovalResponse")] // [JsonDerivedType(typeof(CodeInterpreterToolCallContent), typeDiscriminator: "codeInterpreterToolCall")] // [JsonDerivedType(typeof(CodeInterpreterToolResultContent), typeDiscriminator: "codeInterpreterToolResult")] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs index d3ec7ab8f0b..064dbcd95be 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI; /// -/// Represents a request for user approval of a function call. +/// Represents a request for user approval of a call content. /// [Experimental("MEAI001")] public sealed class FunctionApprovalRequestContent : UserInputRequestContent @@ -17,25 +17,25 @@ public sealed class FunctionApprovalRequestContent : UserInputRequestContent /// Initializes a new instance of the class. /// /// The ID that uniquely identifies the function approval request/response pair. - /// The function call that requires user approval. + /// The call content that requires user approval. /// is . /// is empty or composed entirely of whitespace. - /// is . - public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall) + /// is . + public FunctionApprovalRequestContent(string id, AIContent callContent) : base(id) { - FunctionCall = Throw.IfNull(functionCall); + CallContent = Throw.IfNull(callContent); } /// - /// Gets the function call that pre-invoke approval is required for. + /// Gets the call content that pre-invoke approval is required for. /// - public FunctionCallContent FunctionCall { get; } + public AIContent CallContent { get; } /// - /// Creates a to indicate whether the function call is approved or rejected based on the value of . + /// Creates a to indicate whether the call is approved or rejected based on the value of . /// - /// if the function call is approved; otherwise, . + /// if the call is approved; otherwise, . /// The representing the approval response. - public FunctionApprovalResponseContent CreateResponse(bool approved) => new(Id, approved, FunctionCall); + public FunctionApprovalResponseContent CreateResponse(bool approved) => new(Id, approved, CallContent); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs index 948dc6a1347..c856b2e21d5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI; /// -/// Represents a response to a function approval request. +/// Represents a response to an approval request. /// [Experimental("MEAI001")] public sealed class FunctionApprovalResponseContent : UserInputResponseContent @@ -16,17 +16,17 @@ public sealed class FunctionApprovalResponseContent : UserInputResponseContent /// /// Initializes a new instance of the class. /// - /// The ID that uniquely identifies the function approval request/response pair. - /// if the function call is approved; otherwise, . - /// The function call that requires user approval. + /// The ID that uniquely identifies the approval request/response pair. + /// if the call is approved; otherwise, . + /// The call content that requires user approval. /// is . /// is empty or composed entirely of whitespace. - /// is . - public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall) + /// is . + public FunctionApprovalResponseContent(string id, bool approved, AIContent callContent) : base(id) { Approved = approved; - FunctionCall = Throw.IfNull(functionCall); + CallContent = Throw.IfNull(callContent); } /// @@ -35,7 +35,7 @@ public FunctionApprovalResponseContent(string id, bool approved, FunctionCallCon public bool Approved { get; } /// - /// Gets the function call for which approval was requested. + /// Gets the call content for which approval was requested. /// - public FunctionCallContent FunctionCall { get; } + public AIContent CallContent { get; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs deleted file mode 100644 index 8f302d901b4..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a request for user approval of an MCP server tool call. -/// -[Experimental("MEAI001")] -public sealed class McpServerToolApprovalRequestContent : UserInputRequestContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID that uniquely identifies the MCP server tool approval request/response pair. - /// The tool call that requires user approval. - /// is . - /// is empty or composed entirely of whitespace. - /// is . - public McpServerToolApprovalRequestContent(string id, McpServerToolCallContent toolCall) - : base(id) - { - ToolCall = Throw.IfNull(toolCall); - } - - /// - /// Gets the tool call that pre-invoke approval is required for. - /// - public McpServerToolCallContent ToolCall { get; } - - /// - /// Creates a to indicate whether the function call is approved or rejected based on the value of . - /// - /// if the function call is approved; otherwise, . - /// The representing the approval response. - public McpServerToolApprovalResponseContent CreateResponse(bool approved) => new(Id, approved); -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs deleted file mode 100644 index 0e239a79d7f..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a response to an MCP server tool approval request. -/// -[Experimental("MEAI001")] -public sealed class McpServerToolApprovalResponseContent : UserInputResponseContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID that uniquely identifies the MCP server tool approval request/response pair. - /// if the MCP server tool call is approved; otherwise, . - /// is . - /// is empty or composed entirely of whitespace. - public McpServerToolApprovalResponseContent(string id, bool approved) - : base(id) - { - Approved = approved; - } - - /// - /// Gets a value indicating whether the user approved the request. - /// - public bool Approved { get; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs index b2a2e0e6e95..3e77607a3fd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(FunctionApprovalRequestContent), "functionApprovalRequest")] -[JsonDerivedType(typeof(McpServerToolApprovalRequestContent), "mcpServerToolApprovalRequest")] public class UserInputRequestContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs index 6902f047282..f17ae2a964d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(FunctionApprovalResponseContent), "functionApprovalResponse")] -[JsonDerivedType(typeof(McpServerToolApprovalResponseContent), "mcpServerToolApprovalResponse")] public class UserInputResponseContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index d01294836bc..51ed08441f1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -55,8 +55,6 @@ private static JsonSerializerOptions CreateDefaultOptions() AddAIContentType(options, typeof(FunctionApprovalResponseContent), typeDiscriminatorId: "functionApprovalResponse", checkBuiltIn: false); AddAIContentType(options, typeof(McpServerToolCallContent), typeDiscriminatorId: "mcpServerToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(McpServerToolResultContent), typeDiscriminatorId: "mcpServerToolResult", checkBuiltIn: false); - AddAIContentType(options, typeof(McpServerToolApprovalRequestContent), typeDiscriminatorId: "mcpServerToolApprovalRequest", checkBuiltIn: false); - AddAIContentType(options, typeof(McpServerToolApprovalResponseContent), typeDiscriminatorId: "mcpServerToolApprovalResponse", checkBuiltIn: false); AddAIContentType(options, typeof(CodeInterpreterToolCallContent), typeDiscriminatorId: "codeInterpreterToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false); @@ -129,8 +127,6 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(FunctionApprovalResponseContent))] [JsonSerializable(typeof(McpServerToolCallContent))] [JsonSerializable(typeof(McpServerToolResultContent))] - [JsonSerializable(typeof(McpServerToolApprovalRequestContent))] - [JsonSerializable(typeof(McpServerToolApprovalResponseContent))] [JsonSerializable(typeof(CodeInterpreterToolCallContent))] [JsonSerializable(typeof(CodeInterpreterToolResultContent))] [JsonSerializable(typeof(ResponseContinuationToken))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index eb39754d5fd..cf8ec8a8823 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -212,7 +212,7 @@ internal static IEnumerable ToChatMessages(IEnumerable ToChatMessages(IEnumerable break; case McpToolCallApprovalRequestItem mtcari: - yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + yield return CreateUpdate(new FunctionApprovalRequestContent(mtcari.Id, new McpServerToolCallContent(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) { Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, RawRepresentation = mtcari, @@ -855,7 +851,7 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable rawRep, - McpServerToolApprovalResponseContent mcpResp => ResponseItem.CreateMcpApprovalResponseItem(mcpResp.Id, mcpResp.Approved), + FunctionApprovalResponseContent { CallContent: McpServerToolCallContent mcpCall } farc => ResponseItem.CreateMcpApprovalResponseItem(mcpCall.CallId, farc.Approved), _ => null }; @@ -1052,8 +1048,8 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera } break; - case McpServerToolApprovalResponseContent mcpApprovalResponseContent: - yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); + case FunctionApprovalResponseContent { CallContent: McpServerToolCallContent mcpCall } farc: + yield return ResponseItem.CreateMcpApprovalResponseItem(mcpCall.CallId, farc.Approved); break; } } @@ -1090,12 +1086,12 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); break; - case McpServerToolApprovalRequestContent mcpApprovalRequestContent: + case FunctionApprovalRequestContent { CallContent: McpServerToolCallContent mcpCall }: yield return ResponseItem.CreateMcpApprovalRequestItem( - mcpApprovalRequestContent.Id, - mcpApprovalRequestContent.ToolCall.ServerName, - mcpApprovalRequestContent.ToolCall.ToolName, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(mcpApprovalRequestContent.ToolCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); + mcpCall.CallId, + mcpCall.ServerName, + mcpCall.ToolName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(mcpCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); break; case McpServerToolCallContent mstcc: diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 74f9bf554fa..86e4d011638 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1245,7 +1245,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// /// 1. Remove all and from the . /// 2. Recreate for any that haven't been executed yet. - /// 3. Genreate failed for any rejected . + /// 3. Generate failed for any rejected . /// 4. add all the new content items to and return them as the pre-invocation history. /// private static (List? preDownstreamCallHistory, List? approvals) ProcessFunctionApprovalResponses( @@ -1326,15 +1326,15 @@ private static (List? approvals, List? approvals, List 0 }) { Throw.InvalidOperationException( - $"FunctionApprovalRequestContent found with FunctionCall.CallId(s) '{string.Join(", ", approvalRequestCallIds)}' that have no matching FunctionApprovalResponseContent."); + $"FunctionApprovalRequestContent found with CallId(s) '{string.Join(", ", approvalRequestCallIds)}' that have no matching FunctionApprovalResponseContent."); } // 2nd iteration, over all approval responses: @@ -1393,7 +1393,7 @@ private static (List? approvals, List? approvals, List? targetList = ref approvalResponse.Approved ? ref approvedFunctionCalls : ref rejectedFunctionCalls; ChatMessage? requestMessage = null; - _ = allApprovalRequestsMessages?.TryGetValue(approvalResponse.FunctionCall.CallId, out requestMessage); + _ = allApprovalRequestsMessages?.TryGetValue(fcc.CallId, out requestMessage); (targetList ??= []).Add(new() { Response = approvalResponse, RequestMessage = requestMessage }); } @@ -1418,7 +1418,7 @@ private static (List? approvals, ListThe for the rejected function calls. private static List? GenerateRejectedFunctionResults(List? rejections) => rejections is { Count: > 0 } ? - rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user.")) : + rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.FunctionCallContent.CallId, "Error: Tool call invocation was rejected by user.")) : null; /// @@ -1459,7 +1459,7 @@ private static (List? approvals, List? approvals, List { // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. var modeAndMessages = await ProcessFunctionCallsAsync( - originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); + originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.FunctionCallContent).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); @@ -1720,9 +1720,10 @@ public enum FunctionInvocationStatus Exception, } - private struct ApprovalResultWithRequestMessage + private readonly struct ApprovalResultWithRequestMessage { - public FunctionApprovalResponseContent Response { get; set; } - public ChatMessage? RequestMessage { get; set; } + public FunctionApprovalResponseContent Response { get; init; } + public ChatMessage? RequestMessage { get; init; } + public FunctionCallContent FunctionCallContent => (FunctionCallContent)Response.CallContent; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs index e5734ccd7cf..71ee60c5983 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs @@ -74,8 +74,8 @@ public void Serialization_DerivedTypes_Roundtrips() new FunctionApprovalResponseContent("request123", approved: true, new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), new McpServerToolCallContent("call123", "myTool", "myServer"), new McpServerToolResultContent("call123"), - new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), - new McpServerToolApprovalResponseContent("request123", approved: true) + new FunctionApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + new FunctionApprovalResponseContent("request123", approved: true, new McpServerToolCallContent("call456", "myTool2", "myServer2")) ]); var serialized = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs index 924243a7d1c..bb199def89b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs @@ -19,7 +19,7 @@ public void Constructor_InvalidArguments_Throws() Assert.Throws("id", () => new FunctionApprovalRequestContent("", functionCall)); Assert.Throws("id", () => new FunctionApprovalRequestContent("\r\t\n ", functionCall)); - Assert.Throws("functionCall", () => new FunctionApprovalRequestContent("id", null!)); + Assert.Throws("callContent", () => new FunctionApprovalRequestContent("id", null!)); } [Theory] @@ -33,7 +33,7 @@ public void Constructor_Roundtrips(string id) FunctionApprovalRequestContent content = new(id, functionCall); Assert.Same(id, content.Id); - Assert.Same(functionCall, content.FunctionCall); + Assert.Same(functionCall, content.CallContent); } [Theory] @@ -51,7 +51,7 @@ public void CreateResponse_ReturnsExpectedResponse(bool approved) Assert.NotNull(response); Assert.Same(id, response.Id); Assert.Equal(approved, response.Approved); - Assert.Same(functionCall, response.FunctionCall); + Assert.Same(functionCall, response.CallContent); } [Fact] @@ -64,8 +64,11 @@ public void Serialization_Roundtrips() Assert.NotNull(deserializedContent); Assert.Equal(content.Id, deserializedContent.Id); - Assert.NotNull(deserializedContent.FunctionCall); - Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); - Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + Assert.NotNull(deserializedContent.CallContent); + + var deserializedFunctionCall = Assert.IsType(deserializedContent.CallContent); + var originalFunctionCall = (FunctionCallContent)content.CallContent; + Assert.Equal(originalFunctionCall.CallId, deserializedFunctionCall.CallId); + Assert.Equal(originalFunctionCall.Name, deserializedFunctionCall.Name); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs index 67d2f13cf49..209daeeccac 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs @@ -18,7 +18,7 @@ public void Constructor_InvalidArguments_Throws() Assert.Throws("id", () => new FunctionApprovalResponseContent("", true, functionCall)); Assert.Throws("id", () => new FunctionApprovalResponseContent("\r\t\n ", true, functionCall)); - Assert.Throws("functionCall", () => new FunctionApprovalResponseContent("id", true, null!)); + Assert.Throws("callContent", () => new FunctionApprovalResponseContent("id", true, null!)); } [Theory] @@ -32,7 +32,7 @@ public void Constructor_Roundtrips(string id, bool approved) Assert.Same(id, content.Id); Assert.Equal(approved, content.Approved); - Assert.Same(functionCall, content.FunctionCall); + Assert.Same(functionCall, content.CallContent); } [Fact] @@ -46,8 +46,11 @@ public void Serialization_Roundtrips() Assert.NotNull(deserializedContent); Assert.Equal(content.Id, deserializedContent.Id); Assert.Equal(content.Approved, deserializedContent.Approved); - Assert.NotNull(deserializedContent.FunctionCall); - Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); - Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + Assert.NotNull(deserializedContent.CallContent); + + var deserializedFunctionCall = Assert.IsType(deserializedContent.CallContent); + var originalFunctionCall = (FunctionCallContent)content.CallContent; + Assert.Equal(originalFunctionCall.CallId, deserializedFunctionCall.CallId); + Assert.Equal(originalFunctionCall.Name, deserializedFunctionCall.Name); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs index fc4dac9cabb..9661972112f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs @@ -33,16 +33,19 @@ public void Constructor_Roundtrips(string id) [Fact] public void Serialization_DerivedTypes_Roundtrips() { - UserInputRequestContent content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); + FunctionApprovalRequestContent content = new FunctionApprovalRequestContent( + "request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); - var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); Assert.NotNull(deserializedContent); Assert.Equal(content.GetType(), deserializedContent.GetType()); UserInputRequestContent[] contents = [ new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), - new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + + // Uncomment once McpServerToolCallContent is no longer experimental. + // new FunctionApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), ]; var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputRequestContentArray); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs index 2442e57272d..39d6307fff5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs @@ -40,7 +40,9 @@ public void Serialization_DerivedTypes_Roundtrips() UserInputResponseContent[] contents = [ new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")), - new McpServerToolApprovalResponseContent("request123", true), + + // Uncomment once McpServerToolCallContent is no longer experimental. + // new FunctionApprovalResponseContent("request123", true, new McpServerToolCallContent("call123", "myTool", "myServer")), ]; var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputResponseContentArray); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 830563a60e1..ffde9ad653a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -144,7 +144,7 @@ await client.GetStreamingResponseAsync(Prompt, chatOptions).ToChatResponseAsync( Assert.NotNull(response); Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); - Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType()); Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); } @@ -156,8 +156,8 @@ public async Task RemoteMCP_CallTool_ApprovalRequired() SkipIfNotEnabled(); await RunAsync(false, false, false); - await RunAsync(true, true, false); await RunAsync(false, false, true); + await RunAsync(true, true, false); await RunAsync(true, true, true); async Task RunAsync(bool streaming, bool requireSpecific, bool useConversationId) @@ -198,8 +198,12 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() var approvalResponse = new ChatMessage(ChatRole.Tool, response.Messages .SelectMany(m => m.Contents) - .OfType() - .Select(c => new McpServerToolApprovalResponseContent(c.ToolCall.CallId, true)) + .OfType() + .Select(c => + { + var mcpCallContent = Assert.IsType(c.CallContent); + return new FunctionApprovalResponseContent(mcpCallContent.CallId, true, c.CallContent); + }) .ToArray()); if (approvalResponse.Contents.Count == 0) { @@ -407,8 +411,9 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() if (approval) { input.AddRange(response.Messages); - var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); - Assert.Equal("search_events", approvalRequest.ToolCall.ToolName); + var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + var mcpCallContent = Assert.IsType(approvalRequest.CallContent); + Assert.Equal("search_events", mcpCallContent.ToolName); input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)])); response = streaming ? diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 94d767f67d4..9e55383d751 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -1292,7 +1292,7 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) { Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))] }; - McpServerToolApprovalRequestContent approvalRequest; + FunctionApprovalRequestContent approvalRequest; using (VerbatimHttpHandler handler = new(input, output)) using (HttpClient httpClient = new(handler)) @@ -1302,7 +1302,7 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions); - approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); chatOptions.ConversationId = response.ConversationId; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 7c42c0edaf9..e1cb552543d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Xunit; -namespace Microsoft.Extensions.AI.ChatCompletion; +namespace Microsoft.Extensions.AI; public class FunctionInvokingChatClientApprovalsTests { @@ -571,11 +571,11 @@ public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync() var invokeException = await Assert.ThrowsAsync( async () => await InvokeAndAssertAsync(options, input, [], [], [])); - Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message); + Assert.Equal("FunctionApprovalRequestContent found with CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message); var invokeStreamingException = await Assert.ThrowsAsync( async () => await InvokeAndAssertStreamingAsync(options, input, [], [], [])); - Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message); + Assert.Equal("FunctionApprovalRequestContent found with CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message); } [Fact] @@ -816,24 +816,27 @@ async IAsyncEnumerable YieldInnerClientUpdates( break; case 2: var approvalRequest1 = update.Contents.OfType().First(); - Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId); - Assert.Equal("Func1", approvalRequest1.FunctionCall.Name); + var functionCall1 = Assert.IsType(approvalRequest1.CallContent); + Assert.Equal("callId1", functionCall1.CallId); + Assert.Equal("Func1", functionCall1.Name); // Third content should have been buffered, since we have not yet encountered a function call that requires approval. Assert.Equal(4, updateYieldCount); break; case 3: var approvalRequest2 = update.Contents.OfType().First(); - Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId); - Assert.Equal("Func2", approvalRequest2.FunctionCall.Name); + var functionCall2 = Assert.IsType(approvalRequest2.CallContent); + Assert.Equal("callId2", functionCall2.CallId); + Assert.Equal("Func2", functionCall2.Name); // Fourth content can be yielded immediately, since it is the first function call that requires approval. Assert.Equal(4, updateYieldCount); break; case 4: var approvalRequest3 = update.Contents.OfType().First(); - Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId); - Assert.Equal("Func3", approvalRequest3.FunctionCall.Name); + var functionCall3 = Assert.IsType(approvalRequest3.CallContent); + Assert.Equal("callId1", functionCall3.CallId); + Assert.Equal("Func3", functionCall3.Name); // Fifth content can be yielded immediately, since we previously encountered a function call that requires approval. Assert.Equal(5, updateYieldCount); @@ -844,6 +847,217 @@ async IAsyncEnumerable YieldInnerClientUpdates( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionCallReplacedWithApproval_MixedWithMcpApprovalAsync(bool useAdditionalTools) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")), + new HostedMcpServerTool("myServer", "https://localhost/mcp") + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func"), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ApprovedApprovalResponseIsExecuted_MixedWithMcpApprovalAsync(bool useAdditionalTools) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")), + new HostedMcpServerTool("myServer", "https://localhost/mcp") + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func")), + new FunctionApprovalResponseContent("callId2", true, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", true, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func") + ]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Result 1") + ]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [ + new McpServerToolResultContent("callId2") { Output = [new TextContent("Result 2")] }, + new TextContent("world") + ]) + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func") + ]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Result 1") + ]), + new ChatMessage(ChatRole.Assistant, [ + new McpServerToolResultContent("callId2") { Output = [new TextContent("Result 2")] }, + new TextContent("world") + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RejectedApprovalResponses_MixedWithMcpApprovalAsync(bool useAdditionalTools, bool approveMcp) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")), + new HostedMcpServerTool("myServer", "https://localhost/mcp") + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", !approveMcp, new FunctionCallContent("callId1", "Func")), + new FunctionApprovalResponseContent("callId2", approveMcp, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + ]; + + List expectedDownstreamClientInput = [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", approveMcp, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func") + ]), + new ChatMessage(ChatRole.Tool, + [ + approveMcp ? + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.") : + new FunctionResultContent("callId1", result: "Result 1") + ]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [ + new TextContent("world"), + .. approveMcp ? + [new McpServerToolResultContent("callId2") { Output = [new TextContent("Result 2")] }] : + Array.Empty() + ]) + ]; + + List output = [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func"), + ]), + new ChatMessage(ChatRole.Tool, + [ + approveMcp ? + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.") : + new FunctionResultContent("callId1", result: "Result 1") + ]), + new ChatMessage(ChatRole.Assistant, [ + new TextContent("world"), + .. approveMcp ? + [new McpServerToolResultContent("callId2") { Output = [new TextContent("Result 2")] }] : + Array.Empty() + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input,