Files
Copilot 88ea9d08c7 .NET: Update to OpenAI 2.9.1, Azure.AI.OpenAI 2.9.0-beta.1, Microsoft.Extensions.AI 10.4.0, and Azure.AI.Projects 2.0.0-beta.2 (#4613)
* Initial plan

* Update code for Microsoft.Extensions.AI.Abstractions 10.4.0 breaking changes

- Rename FunctionApprovalRequestContent → ToolApprovalRequestContent
- Rename FunctionApprovalResponseContent → ToolApprovalResponseContent
- Rename UserInputRequestContent → ToolApprovalRequestContent
- Rename UserInputResponseContent → ToolApprovalResponseContent
- Update .FunctionCall property → .ToolCall with FunctionCallContent casts where needed
- Update .Id property → .RequestId on the renamed types
- Rename FunctionApprovalRequestEventGenerator → ToolApprovalRequestEventGenerator
- Rename FunctionApprovalResponseEventGenerator → ToolApprovalResponseEventGenerator

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update OpenAI 2.9.1, ME.AI 10.4.0, fix breaking API changes

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix remaining ME.AI 10.4.0 breaking changes: MCP approval types, .Output→.Outputs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Use pattern matching with `when` for ToolApprovalRequestContent/FunctionCallContent

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Update Azure.AI.OpenAI to 2.9.0-beta.1

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix remaining GetResponsesClient(model) build failures for Azure.AI.OpenAI 2.9.0-beta.1

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Address review feedback: remove redundant type checks in TestRequestAgent.cs and fix error message in AIAgentHostExecutor.cs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Update Azure.AI.Projects to 2.0.0-beta.2 with namespace migration

- Azure.AI.Projects 2.0.0-beta.1 → 2.0.0-beta.2
- Azure.AI.Projects.OpenAI → Azure.AI.Extensions.OpenAI (transitive)
- Agent types moved to Azure.AI.Projects.Agents namespace
- AgentRecord.Versions.Latest → AgentRecord.GetLatestVersion()
- OpenAPIFunctionDefinition → OpenApiFunctionDefinition
- BingCustomSearchToolParameters → BingCustomSearchToolOptions
- MemorySearchPreviewTool.UpdateDelay → UpdateDelayInSecs
- Azure.Identity 1.17.1 → 1.19.0
- Microsoft.Identity.Client.Extensions.Msal 4.78.0 → 4.83.1

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix remaining type renames for Azure.AI.Projects 2.0.0-beta.2

- BrowserAutomationToolParameters → BrowserAutomationToolOptions
- MemoryUpdateOptions.UpdateDelay stays as UpdateDelay (not renamed)
- WaitForMemoriesUpdateAsync parameter order: pollingInterval before options
- AIProjectAgentsOperations → AgentsClient

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix format errors and OpenTelemetry test for ME.AI 10.4.0

- Remove unused 'using Azure.AI.Extensions.OpenAI' and fix import ordering
  in Agent_With_AzureAIProject/Program.cs
- Update OpenTelemetryAgentTests: gen_ai.tool.definitions is now always
  emitted regardless of EnableSensitiveData per ME.AI 10.4.0 change
  (dotnet/extensions#7346). Tool definitions are not considered sensitive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix GetRepoFolder() to work in git worktrees

Use 'workflow-samples' directory as repo root marker instead of '.git',
which fails in worktrees (.git is a file) and also matches too early
when a '.github' folder exists in subdirectories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix formatting: remove unused usings and fix import ordering

dotnet format applied across 59 impacted projects. Primarily removes
unnecessary 'using Azure.AI.Projects' where Azure.AI.Projects.Agents
provides all needed types, and fixes import ordering per editorconfig.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Disable AzureAIAgentsPersistent integration tests (#4769)

Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent
which was removed in ME.AI 10.4.0 (renamed to ToolApprovalResponseContent), causing
TypeLoadException at runtime. Mark all 6 test classes with IntegrationDisabled trait
until Persistent ships a version targeting ME.AI 10.4.0+.

Upstream fix: https://github.com/Azure/azure-sdk-for-net/pull/56929

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add README with compatibility note for AzureAI.Persistent (#4769)

Documents that Azure.AI.Agents.Persistent 1.2.0-beta.9 is only compatible
with ME.AI ≤10.3.0 and OpenAI ≤2.8.0 due to type renames in ME.AI 10.4.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix file encoding: restore UTF-8 BOM on Persistent test files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Mark AzureAI.Persistent as IsPackable=false (#4769)

Prevent shipping until Azure.AI.Agents.Persistent targets ME.AI 10.4.0+.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Moving IsPackable after import

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
2026-03-20 14:29:29 +00:00

367 lines
16 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Tests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
/// <summary>
/// Tests for function approval request and response content types.
/// These are DevUI-specific extensions that allow approval workflows for function calls.
/// </summary>
public sealed class FunctionApprovalTests : ConformanceTestBase
{
// Streaming request JSON for OpenAI Responses API
private const string StreamingRequestJson = @"{""model"":""gpt-4o-mini"",""input"":""test"",""stream"":true}";
#region ToolApprovalRequestContent Tests
[Fact]
public async Task FunctionApprovalRequest_GeneratesCorrectEvent_SuccessAsync()
{
// Arrange
const string AgentName = "approval-request-agent";
const string RequestId = "req-123";
const string FunctionName = "get_weather";
const string FunctionId = "call-abc123";
Dictionary<string, object?> arguments = new() { ["location"] = "Seattle", ["unit"] = "celsius" };
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new(FunctionId, FunctionName, arguments);
ToolApprovalRequestContent approvalRequest = new(RequestId, functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalRequest]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
Assert.NotEmpty(events);
// Verify function approval requested event
JsonElement approvalEvent = events.FirstOrDefault(e =>
e.GetProperty("type").GetString() == "response.function_approval.requested");
Assert.True(approvalEvent.ValueKind != JsonValueKind.Undefined, "approval event not found");
Assert.Equal(RequestId, approvalEvent.GetProperty("request_id").GetString());
JsonElement functionCallElement = approvalEvent.GetProperty("function_call");
Assert.Equal(FunctionId, functionCallElement.GetProperty("id").GetString());
Assert.Equal(FunctionName, functionCallElement.GetProperty("name").GetString());
JsonElement argumentsElement = functionCallElement.GetProperty("arguments");
Assert.Equal("Seattle", argumentsElement.GetProperty("location").GetString());
Assert.Equal("celsius", argumentsElement.GetProperty("unit").GetString());
}
[Fact]
public async Task FunctionApprovalRequest_WithComplexArguments_GeneratesCorrectEvent_SuccessAsync()
{
// Arrange
const string AgentName = "approval-request-complex-args-agent";
const string RequestId = "req-456";
const string FunctionName = "calculate";
const string FunctionId = "call-def456";
Dictionary<string, object?> arguments = new()
{
["expression"] = "2+2",
["precision"] = 2,
["options"] = new Dictionary<string, object?> { ["decimal"] = true }
};
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new(FunctionId, FunctionName, arguments);
ToolApprovalRequestContent approvalRequest = new(RequestId, functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalRequest]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
JsonElement approvalEvent = events.FirstOrDefault(e =>
e.GetProperty("type").GetString() == "response.function_approval.requested");
Assert.NotEqual(JsonValueKind.Undefined, approvalEvent.ValueKind);
JsonElement functionCallElement = approvalEvent.GetProperty("function_call");
JsonElement argumentsElement = functionCallElement.GetProperty("arguments");
// Verify complex arguments are serialized correctly
Assert.Equal("2+2", argumentsElement.GetProperty("expression").GetString());
Assert.Equal(2, argumentsElement.GetProperty("precision").GetInt32());
Assert.True(argumentsElement.GetProperty("options").GetProperty("decimal").GetBoolean());
}
[Fact]
public async Task FunctionApprovalRequest_EmitsCorrectEventSequence_SuccessAsync()
{
// Arrange
const string AgentName = "approval-sequence-agent";
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new("call-1", "test_function", new Dictionary<string, object?>());
ToolApprovalRequestContent approvalRequest = new("req-1", functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalRequest]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert - Verify event sequence
List<string?> eventTypes = events.ConvertAll(e => e.GetProperty("type").GetString());
Assert.Equal("response.created", eventTypes[0]);
Assert.Equal("response.in_progress", eventTypes[1]);
Assert.Contains("response.function_approval.requested", eventTypes);
Assert.Contains("response.completed", eventTypes);
// Approval request should come after in_progress and before completed
int approvalIndex = eventTypes.IndexOf("response.function_approval.requested");
int inProgressIndex = eventTypes.IndexOf("response.in_progress");
int completedIndex = eventTypes.IndexOf("response.completed");
Assert.True(approvalIndex > inProgressIndex);
Assert.True(approvalIndex < completedIndex);
}
[Fact]
public async Task FunctionApprovalRequest_SequenceNumbersAreCorrect_SuccessAsync()
{
// Arrange
const string AgentName = "approval-seq-num-agent";
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new("call-1", "test", new Dictionary<string, object?>());
ToolApprovalRequestContent approvalRequest = new("req-1", functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalRequest]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert - Sequence numbers are sequential
List<int> sequenceNumbers = events.ConvertAll(e => e.GetProperty("sequence_number").GetInt32());
Assert.NotEmpty(sequenceNumbers);
for (int i = 0; i < sequenceNumbers.Count; i++)
{
Assert.Equal(i, sequenceNumbers[i]);
}
}
#endregion
#region ToolApprovalResponseContent Tests
[Fact]
public async Task FunctionApprovalResponse_Approved_GeneratesCorrectEvent_SuccessAsync()
{
// Arrange
const string AgentName = "approval-response-approved-agent";
const string RequestId = "req-789";
const string FunctionName = "send_email";
const string FunctionId = "call-ghi789";
Dictionary<string, object?> arguments = new() { ["to"] = "user@example.com", ["subject"] = "Test" };
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new(FunctionId, FunctionName, arguments);
ToolApprovalResponseContent approvalResponse = new(RequestId, approved: true, functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalResponse]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
Assert.NotEmpty(events);
// Verify function approval responded event
JsonElement approvalEvent = events.FirstOrDefault(e =>
e.GetProperty("type").GetString() == "response.function_approval.responded");
Assert.True(approvalEvent.ValueKind != JsonValueKind.Undefined, "approval response event not found");
Assert.Equal(RequestId, approvalEvent.GetProperty("request_id").GetString());
Assert.True(approvalEvent.GetProperty("approved").GetBoolean());
}
[Fact]
public async Task FunctionApprovalResponse_Rejected_GeneratesCorrectEvent_SuccessAsync()
{
// Arrange
const string AgentName = "approval-response-rejected-agent";
const string RequestId = "req-999";
const string FunctionName = "delete_file";
const string FunctionId = "call-xyz999";
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new(FunctionId, FunctionName, new Dictionary<string, object?> { ["path"] = "/important.txt" });
ToolApprovalResponseContent approvalResponse = new(RequestId, approved: false, functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalResponse]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
JsonElement approvalEvent = events.FirstOrDefault(e =>
e.GetProperty("type").GetString() == "response.function_approval.responded");
Assert.NotEqual(JsonValueKind.Undefined, approvalEvent.ValueKind);
Assert.Equal(RequestId, approvalEvent.GetProperty("request_id").GetString());
Assert.False(approvalEvent.GetProperty("approved").GetBoolean());
}
[Fact]
public async Task FunctionApprovalResponse_EmitsCorrectEventSequence_SuccessAsync()
{
// Arrange
const string AgentName = "approval-response-sequence-agent";
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new("call-1", "test_function", new Dictionary<string, object?>());
ToolApprovalResponseContent approvalResponse = new("req-1", approved: true, functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[approvalResponse]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
List<string?> eventTypes = events.ConvertAll(e => e.GetProperty("type").GetString());
Assert.Contains("response.function_approval.responded", eventTypes);
Assert.Contains("response.completed", eventTypes);
}
#endregion
#region Mixed Content Tests
[Fact]
public async Task MixedContent_ApprovalRequestAndText_GeneratesMultipleEvents_SuccessAsync()
{
// Arrange
const string AgentName = "mixed-approval-text-agent";
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall = new("call-mixed-1", "test", new Dictionary<string, object?>());
ToolApprovalRequestContent approvalRequest = new("req-mixed-1", functionCall);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[
new TextContent("I need approval for this function:"),
approvalRequest
]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
List<string?> eventTypes = events.ConvertAll(e => e.GetProperty("type").GetString());
Assert.Contains("response.output_item.added", eventTypes);
Assert.Contains("response.function_approval.requested", eventTypes);
}
[Fact]
public async Task MixedContent_MultipleApprovalRequests_GeneratesMultipleEvents_SuccessAsync()
{
// Arrange
const string AgentName = "multiple-approval-agent";
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates
FunctionCallContent functionCall1 = new("call-multi-1", "function1", new Dictionary<string, object?>());
ToolApprovalRequestContent approvalRequest1 = new("req-multi-1", functionCall1);
FunctionCallContent functionCall2 = new("call-multi-2", "function2", new Dictionary<string, object?>());
ToolApprovalRequestContent approvalRequest2 = new("req-multi-2", functionCall2);
#pragma warning restore MEAI001
HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) =>
[
approvalRequest1,
approvalRequest2
]);
// Act
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
List<JsonElement> events = ParseSseEvents(sseContent);
// Assert
List<JsonElement> approvalEvents = events.Where(e =>
e.GetProperty("type").GetString() == "response.function_approval.requested").ToList();
Assert.Equal(2, approvalEvents.Count);
Assert.Equal("req-multi-1", approvalEvents[0].GetProperty("request_id").GetString());
Assert.Equal("req-multi-2", approvalEvents[1].GetProperty("request_id").GetString());
}
#endregion
#region Helper Methods
private static List<JsonElement> ParseSseEvents(string sseContent)
{
List<JsonElement> events = [];
string[] lines = sseContent.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i].TrimEnd('\r');
if (line.StartsWith("event: ", StringComparison.Ordinal) && i + 1 < lines.Length)
{
string dataLine = lines[i + 1].TrimEnd('\r');
if (dataLine.StartsWith("data: ", StringComparison.Ordinal))
{
string jsonData = dataLine.Substring("data: ".Length);
JsonDocument doc = JsonDocument.Parse(jsonData);
events.Add(doc.RootElement.Clone());
}
}
}
return events;
}
#endregion
}