Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public FunctionApprovalRequestContent(string id, FunctionCallContent functionCal
/// Creates a <see cref="FunctionApprovalResponseContent"/> to indicate whether the function call is approved or rejected based on the value of <paramref name="approved"/>.
/// </summary>
/// <param name="approved"><see langword="true"/> if the function call is approved; otherwise, <see langword="false"/>.</param>
/// <param name="reason">An optional reason for the approval or rejection.</param>
/// <returns>The <see cref="FunctionApprovalResponseContent"/> representing the approval response.</returns>
public FunctionApprovalResponseContent CreateResponse(bool approved) => new(Id, approved, FunctionCall);
public FunctionApprovalResponseContent CreateResponse(bool approved, string? reason = null) => new(Id, approved, FunctionCall) { Reason = reason };
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ public FunctionApprovalResponseContent(string id, bool approved, FunctionCallCon
/// Gets the function call for which approval was requested.
/// </summary>
public FunctionCallContent FunctionCall { get; }

/// <summary>
/// Gets or sets the optional reason for the approval or rejection.
/// </summary>
public string? Reason { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -1418,7 +1418,16 @@ private static (List<ApprovalResultWithRequestMessage>? approvals, List<Approval
/// <returns>The <see cref="AIContent"/> for the rejected function calls.</returns>
private static List<AIContent>? GenerateRejectedFunctionResults(List<ApprovalResultWithRequestMessage>? 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(m =>
{
string result = "Tool call invocation rejected.";
if (!string.IsNullOrWhiteSpace(m.Response.Reason))
{
result = $"{result} {m.Response.Reason}";
}

return (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, result);
}) :
null;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ public void CreateResponse_ReturnsExpectedResponse(bool approved)
Assert.Same(id, response.Id);
Assert.Equal(approved, response.Approved);
Assert.Same(functionCall, response.FunctionCall);
Assert.Null(response.Reason);
}

[Theory]
[InlineData(true, "Approved for testing")]
[InlineData(false, "Rejected due to security concerns")]
[InlineData(true, null)]
[InlineData(false, null)]
public void CreateResponse_WithReason_ReturnsExpectedResponse(bool approved, string? reason)
{
string id = "req-1";
FunctionCallContent functionCall = new("FCC1", "TestFunction");

FunctionApprovalRequestContent content = new(id, functionCall);

var response = content.CreateResponse(approved, reason);

Assert.NotNull(response);
Assert.Same(id, response.Id);
Assert.Equal(approved, response.Approved);
Assert.Same(functionCall, response.FunctionCall);
Assert.Equal(reason, response.Reason);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,23 @@ public void Constructor_Roundtrips(string id, bool approved)
Assert.Same(functionCall, content.FunctionCall);
}

[Fact]
public void Serialization_Roundtrips()
[Theory]
[InlineData(null)]
[InlineData("Custom rejection reason")]
public void Serialization_Roundtrips(string? reason)
{
var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName"));
var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName"))
{
Reason = reason
};

var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions);
var deserializedContent = JsonSerializer.Deserialize<FunctionApprovalResponseContent>(json, AIJsonUtilities.DefaultOptions);

Assert.NotNull(deserializedContent);
Assert.Equal(content.Id, deserializedContent.Id);
Assert.Equal(content.Approved, deserializedContent.Approved);
Assert.Equal(content.Reason, deserializedContent.Reason);
Assert.NotNull(deserializedContent.FunctionCall);
Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId);
Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ public async Task RejectedApprovalResponsesAreFailedAsync()
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."),
new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.")
new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
]),
];

Expand All @@ -346,8 +346,8 @@ public async Task RejectedApprovalResponsesAreFailedAsync()
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."),
new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.")
new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
]),
new ChatMessage(ChatRole.Assistant, "world"),
];
Expand Down Expand Up @@ -388,7 +388,7 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
];

Expand All @@ -400,7 +400,7 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
List<ChatMessage> nonStreamingOutput =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Tool call invocation rejected.")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
new ChatMessage(ChatRole.Assistant, "world"),
];
Expand All @@ -410,7 +410,7 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."),
new FunctionResultContent("callId1", result: "Tool call invocation rejected."),
new FunctionResultContent("callId2", result: "Result 2: 42")
]),
new ChatMessage(ChatRole.Assistant, "world"),
Expand All @@ -421,6 +421,222 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
}

[Fact]
public async Task RejectedApprovalResponsesWithCustomReasonAsync()
{
var options = new ChatOptions
{
Tools =
[
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
]
};

List<ChatMessage> input =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
]) { MessageId = "resp1" },
new ChatMessage(ChatRole.User,
[
new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1"))
{
Reason = "User denied permission for this operation"
},
new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
{
Reason = "Function Func2 is not allowed at this time"
}
]),
];

List<ChatMessage> expectedDownstreamClientInput =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected. User denied permission for this operation"),
new FunctionResultContent("callId2", result: "Tool call invocation rejected. Function Func2 is not allowed at this time")
]),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> output =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected. User denied permission for this operation"),
new FunctionResultContent("callId2", result: "Tool call invocation rejected. Function Func2 is not allowed at this time")
]),
new ChatMessage(ChatRole.Assistant, "world"),
];

await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);

await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
}

[Fact]
public async Task MixedApprovalResponsesWithCustomAndDefaultReasonsAsync()
{
var options = new ChatOptions
{
Tools =
[
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
AIFunctionFactory.Create((string s) => $"Result 3: {s}", "Func3"),
]
};

List<ChatMessage> input =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })),
new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } }))
]) { MessageId = "resp1" },
new ChatMessage(ChatRole.User,
[
new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "Custom rejection for Func1" },
new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })),
new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } }))
]),
];

List<ChatMessage> expectedDownstreamClientInput =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> nonStreamingOutput =
[
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
new FunctionResultContent("callId2", result: "Tool call invocation rejected.")
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 3: test")]),
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> streamingOutput =
[
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("callId1", "Func1"),
new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }),
new FunctionCallContent("callId3", "Func3", arguments: new Dictionary<string, object?> { { "s", "test" } })
]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected. Custom rejection for Func1"),
new FunctionResultContent("callId2", result: "Tool call invocation rejected."),
new FunctionResultContent("callId3", result: "Result 3: test")
]),
new ChatMessage(ChatRole.Assistant, "world"),
];

await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput);

await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task RejectedApprovalResponsesWithEmptyOrWhitespaceReasonUsesDefaultMessageAsync(string? reason)
{
var options = new ChatOptions
{
Tools =
[
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
]
};

List<ChatMessage> input =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
]) { MessageId = "resp1" },
new ChatMessage(ChatRole.User,
[
new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1"))
{
Reason = reason
},
]),
];

List<ChatMessage> expectedDownstreamClientInput =
[
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
]),
];

List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, "world"),
];

List<ChatMessage> output =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]),
new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("callId1", result: "Tool call invocation rejected.")
]),
new ChatMessage(ChatRole.Assistant, "world"),
];

await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);

await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
}

[Fact]
public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync()
{
Expand Down
Loading