mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dev/dotnet_workflow/magentic
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
.git/
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
*.user
|
||||
*.suo
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "foundry-hosting-it-test-container.dll"]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Foundry.Hosting.IntegrationTests.TestContainer</RootNamespace>
|
||||
<AssemblyName>foundry-hosting-it-test-container</AssemblyName>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<UseMicrosoftTestingPlatformRunner>false</UseMicrosoftTestingPlatformRunner>
|
||||
<TestingPlatformDotnetTestSupport>false</TestingPlatformDotnetTestSupport>
|
||||
<NoWarn>$(NoWarn);NU1605;NU1903;AAIP001;OPENAI001</NoWarn>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="xunit.v3.mtp-v2" />
|
||||
<PackageReference Remove="xunit.runner.visualstudio" />
|
||||
<PackageReference Remove="Moq" />
|
||||
<PackageReference Remove="xRetry.v3" />
|
||||
<PackageReference Remove="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
|
||||
<Using Remove="Xunit" />
|
||||
<Using Remove="xRetry.v3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Foundry hosted agent test container for Foundry.Hosting.IntegrationTests.
|
||||
//
|
||||
// One image, many scenarios. The IT_SCENARIO environment variable selects which agent
|
||||
// behavior is wired up at startup. Each scenario corresponds to one test fixture and
|
||||
// one set of tests in the IT project.
|
||||
//
|
||||
// The platform injects FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_AGENT_NAME, FOUNDRY_AGENT_VERSION,
|
||||
// PORT, and APPLICATIONINSIGHTS_CONNECTION_STRING. We never set FOUNDRY_* or AGENT_* names
|
||||
// from the test side because they are reserved by the platform.
|
||||
|
||||
var scenario = Environment.GetEnvironmentVariable("IT_SCENARIO") ?? "happy-path";
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
var projectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential());
|
||||
|
||||
AIAgent agent = scenario switch
|
||||
{
|
||||
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
|
||||
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
|
||||
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
|
||||
"toolbox" => CreateToolboxAgent(projectClient, deployment),
|
||||
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
|
||||
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var port = Environment.GetEnvironmentVariable("PORT");
|
||||
if (!string.IsNullOrEmpty(port))
|
||||
{
|
||||
builder.WebHost.UseUrls($"http://+:{port}");
|
||||
}
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.MapGet("/readiness", () => Results.Ok());
|
||||
app.Run();
|
||||
|
||||
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful AI assistant. Always reply with exactly the single word ECHO unless the user explicitly asks a question that requires a different answer.",
|
||||
name: "happy-path-agent",
|
||||
description: "Round trip and conversation test agent.");
|
||||
|
||||
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful assistant. Use the GetUtcNow and Multiply tools when appropriate.",
|
||||
name: "tool-calling-agent",
|
||||
description: "Server side tool calling test agent.",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetUtcNow),
|
||||
AIFunctionFactory.Create(Multiply)
|
||||
]);
|
||||
|
||||
static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire approval required AIFunction once the public surface is finalized.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful assistant. Use the SendEmail tool when asked to send a message; it requires user approval before running.",
|
||||
name: "tool-calling-approval-agent",
|
||||
description: "Approval flow test agent (placeholder).",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(SendEmail)
|
||||
]);
|
||||
|
||||
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
|
||||
name: "toolbox-agent",
|
||||
description: "Toolbox test agent (placeholder).",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetEnvironmentName)
|
||||
]);
|
||||
|
||||
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are an assistant with access to Microsoft Learn documentation via MCP.",
|
||||
name: "mcp-toolbox-agent",
|
||||
description: "MCP toolbox test agent (placeholder).");
|
||||
|
||||
static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: substitute custom IResponsesStorageProvider in DI.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful assistant.",
|
||||
name: "custom-storage-agent",
|
||||
description: "Custom storage test agent (placeholder).");
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
[Description("Multiplies two integers and returns the product.")]
|
||||
static int Multiply([Description("First operand")] int a, [Description("Second operand")] int b) => a * b;
|
||||
|
||||
[Description("Sends an email. Requires user approval.")]
|
||||
static string SendEmail(
|
||||
[Description("Recipient address")] string to,
|
||||
[Description("Email subject")] string subject) =>
|
||||
$"Email sent to {to} with subject '{subject}'.";
|
||||
|
||||
[Description("Returns the deployment environment name.")]
|
||||
static string GetEnvironmentName() => "integration-test";
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for a hosted agent whose container wires an in memory custom storage provider
|
||||
/// in place of the platform default. Verifies the model still works and that multi turn
|
||||
/// behavior reads from the custom store.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class CustomStorageHostedAgentTests(CustomStorageHostedAgentFixture fixture)
|
||||
: IClassFixture<CustomStorageHostedAgentFixture>
|
||||
{
|
||||
private readonly CustomStorageHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task RoundTrip_WorksWithCustomStorageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with the word 'stored'.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task MultiTurn_PreviousResponseId_ReadsFromCustomStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("My favorite city is Lisbon. Acknowledge briefly.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("What city did I just tell you?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Lisbon", second.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=custom-storage</c> mode.
|
||||
/// The container substitutes the default Responses storage provider with a custom in memory
|
||||
/// implementation so tests can verify that conversation history is read from and written to
|
||||
/// the custom store rather than the platform default.
|
||||
/// </summary>
|
||||
public sealed class CustomStorageHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "custom-storage";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=happy-path</c> mode.
|
||||
/// Used by tests that exercise the basic Responses protocol round trip, multi turn behavior
|
||||
/// (via <c>previous_response_id</c> and <c>conversation_id</c>), and the <c>stored=false</c> flag.
|
||||
/// </summary>
|
||||
public sealed class HappyPathHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "happy-path";
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Base fixture for Foundry Hosted Agent integration tests.
|
||||
///
|
||||
/// Each derived fixture represents one scenario (happy path, tool calling, toolbox, etc.) and
|
||||
/// targets a stable, scenario-keyed agent name (e.g. <c>it-happy-path</c>). The fixture creates
|
||||
/// a new <see cref="ProjectsAgentVersion"/> on each <see cref="InitializeAsync"/>, polls until
|
||||
/// active, patches the agent's endpoint to route 100% of traffic to that new version, then
|
||||
/// exposes the wrapped <see cref="AIAgent"/> for tests via <see cref="Agent"/>.
|
||||
///
|
||||
/// On <see cref="DisposeAsync"/> only the version created by this fixture is removed; the agent
|
||||
/// itself (and therefore its managed identity) is left in place. This is critical because the
|
||||
/// agent's managed identity must hold <c>Azure AI User</c> on the project scope to serve
|
||||
/// inbound inference traffic, and that role assignment is lost when the agent itself is deleted.
|
||||
///
|
||||
/// Prerequisite: each scenario agent (and its managed identity) must exist and have
|
||||
/// <c>Azure AI User</c> pre-granted on the project scope before the tests run. See
|
||||
/// <c>scripts/it-bootstrap-agents.ps1</c>.
|
||||
///
|
||||
/// The container image is the same for every scenario; the scenario itself is selected by
|
||||
/// the <c>IT_SCENARIO</c> environment variable in <see cref="HostedAgentDefinition.EnvironmentVariables"/>,
|
||||
/// configured by each derived fixture via <see cref="ScenarioName"/>.
|
||||
/// </summary>
|
||||
public abstract class HostedAgentFixture : IAsyncLifetime
|
||||
{
|
||||
private const string ScenarioEnvironmentVariable = "IT_SCENARIO";
|
||||
private const string RunIdEnvironmentVariable = "IT_RUN_ID";
|
||||
private const string FoundryFeaturesHeader = "Foundry-Features";
|
||||
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview";
|
||||
private const string EnableVnextExperienceMetadataKey = "enableVnextExperience";
|
||||
|
||||
private AgentAdministrationClient _adminClient = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Scenario keyword passed to the container as <c>IT_SCENARIO</c>. Derived fixtures override.
|
||||
/// </summary>
|
||||
protected abstract string ScenarioName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU request for the hosted agent container. Override per scenario if needed.
|
||||
/// </summary>
|
||||
protected virtual string Cpu => "0.25";
|
||||
|
||||
/// <summary>
|
||||
/// Memory request for the hosted agent container. Override per scenario if needed.
|
||||
/// </summary>
|
||||
protected virtual string Memory => "0.5Gi";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time to wait for <see cref="AgentVersionStatus.Active"/> after creation.
|
||||
/// </summary>
|
||||
protected virtual TimeSpan ProvisioningTimeout => TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// The wrapped agent. Available after <see cref="InitializeAsync"/>.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The stable, scenario keyed agent name registered in Foundry (e.g. <c>it-happy-path</c>).
|
||||
/// The agent itself is provisioned out of band (see <c>scripts/it-bootstrap-agents.ps1</c>);
|
||||
/// each test run only adds and removes a version under it.
|
||||
/// </summary>
|
||||
public string AgentName { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The agent version assigned by Foundry on creation.
|
||||
/// </summary>
|
||||
public string AgentVersion { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying <see cref="AIProjectClient"/>, useful for tests that need to talk
|
||||
/// to the conversations or responses APIs directly (e.g. to assert chain visibility).
|
||||
/// </summary>
|
||||
public AIProjectClient ProjectClient { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a server side conversation that tests can pass via <c>ChatOptions.ConversationId</c>
|
||||
/// to exercise multi turn flows backed by the Foundry conversations service.
|
||||
/// </summary>
|
||||
public async Task<string> CreateConversationAsync()
|
||||
{
|
||||
var response = await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync().ConfigureAwait(false);
|
||||
return response.Value.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a previously created conversation. Used by tests in their cleanup blocks.
|
||||
/// </summary>
|
||||
public async Task DeleteConversationAsync(string conversationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(conversationId).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup mirroring DisposeAsync.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts items currently stored in a conversation. Used by tests verifying that a
|
||||
/// <c>stored=false</c> request did not append to the conversation.
|
||||
/// </summary>
|
||||
public async Task<int> CountConversationItemsAsync(string conversationId)
|
||||
{
|
||||
var count = 0;
|
||||
await foreach (var _ in this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
|
||||
var image = TestConfiguration.GetRequiredValue(TestSettings.FoundryHostingItImage);
|
||||
|
||||
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
|
||||
|
||||
var adminOptions = new AgentAdministrationClientOptions();
|
||||
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
|
||||
this._adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
|
||||
this.ProjectClient = new AIProjectClient(endpoint, credential);
|
||||
|
||||
this.AgentName = $"it-{this.ScenarioName}";
|
||||
|
||||
var definition = new HostedAgentDefinition(cpu: this.Cpu, memory: this.Memory)
|
||||
{
|
||||
Image = image,
|
||||
};
|
||||
definition.Versions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0"));
|
||||
definition.EnvironmentVariables[ScenarioEnvironmentVariable] = this.ScenarioName;
|
||||
// Foundry deduplicates versions by content hash, so a fixture re-using the same
|
||||
// definition would just receive the bootstrap version and then delete it on dispose.
|
||||
// Adding a per-run env var forces a brand new version that the dispose can safely remove
|
||||
// without touching the bootstrap version (which keeps the agent alive across runs).
|
||||
definition.EnvironmentVariables[RunIdEnvironmentVariable] = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Allow derived fixtures to layer additional environment variables before submission.
|
||||
this.ConfigureEnvironment(definition.EnvironmentVariables);
|
||||
|
||||
var creationOptions = new ProjectsAgentVersionCreationOptions(definition);
|
||||
creationOptions.Metadata[EnableVnextExperienceMetadataKey] = "true";
|
||||
|
||||
// Adds a new version under the (stable) agent name. Auto-creates the agent on first run.
|
||||
// The agent is intentionally never deleted because its managed identity must hold the
|
||||
// pre-granted role assignment for inbound inference to succeed (see class docs).
|
||||
var version = await this._adminClient.CreateAgentVersionAsync(this.AgentName, creationOptions).ConfigureAwait(false);
|
||||
var activeVersion = await WaitForActiveAsync(this._adminClient, version.Value, this.ProvisioningTimeout).ConfigureAwait(false);
|
||||
this.AgentVersion = activeVersion.Version;
|
||||
|
||||
// The agent endpoint must already be configured to route via @latest. The bootstrap
|
||||
// script (scripts/it-bootstrap-agents.ps1) does that one-time per agent. Each new
|
||||
// version we create automatically becomes the served one because @latest resolves
|
||||
// to the highest version number.
|
||||
//
|
||||
// Build a per-agent ProjectOpenAIClient (the cached projectClient.ProjectOpenAIClient is bound
|
||||
// to the project-level URL and cannot serve a hosted agent). AgentName on the options selects
|
||||
// the per-agent URL suffix `/agents/{name}/endpoint/protocols/openai`. The Foundry-Features
|
||||
// header is also required on the invocation pipeline (not just the admin one) for hosted agents.
|
||||
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this.AgentName };
|
||||
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
|
||||
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
|
||||
var responsesClient = openAIClient.GetProjectResponsesClient();
|
||||
|
||||
this.Agent = responsesClient.AsIChatClient().AsAIAgent(name: this.AgentName);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (this._adminClient is null || this.AgentName is null || this.AgentVersion is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Delete only the version we created. The agent itself MUST stay so that its
|
||||
// managed identity (and the pre-granted Azure AI User role on it) survive across
|
||||
// test runs. If we delete the agent, Foundry mints a new MI on the next create
|
||||
// and inference fails with PermissionDenied until the role is regranted.
|
||||
await this._adminClient.DeleteAgentVersionAsync(this.AgentName, this.AgentVersion).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup. Never throw from DisposeAsync because that would mask
|
||||
// the real test failure. Orphan versions accumulate harmlessly; a maintenance
|
||||
// script can prune them when needed.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hook for derived fixtures to add scenario specific environment variables.
|
||||
/// Reserved names (anything matching <c>FOUNDRY_*</c> or <c>AGENT_*</c>) are forbidden by the platform.
|
||||
/// </summary>
|
||||
protected virtual void ConfigureEnvironment(IDictionary<string, string> environment)
|
||||
{
|
||||
}
|
||||
|
||||
private static async Task<ProjectsAgentVersion> WaitForActiveAsync(
|
||||
AgentAdministrationClient adminClient,
|
||||
ProjectsAgentVersion version,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (version.Status != AgentVersionStatus.Active && version.Status != AgentVersionStatus.Failed)
|
||||
{
|
||||
if (DateTimeOffset.UtcNow > deadline)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Hosted agent '{version.Name}' version '{version.Version}' did not become Active within {timeout.TotalSeconds:F0}s. Last status: {version.Status}.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None).ConfigureAwait(false);
|
||||
version = (await adminClient.GetAgentVersionAsync(version.Name, version.Version).ConfigureAwait(false)).Value;
|
||||
}
|
||||
|
||||
if (version.Status != AgentVersionStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Hosted agent '{version.Name}' version '{version.Version}' failed to deploy. Status: {version.Status}.");
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that adds the Foundry feature header on every request.
|
||||
/// Required for hosted agent operations until the V1 preview flag is removed.
|
||||
/// </summary>
|
||||
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void SetHeader(PipelineMessage message)
|
||||
{
|
||||
// Set rather than Add to avoid duplicate headers if the pipeline reprocesses
|
||||
// the request (retries) or if multiple policies attempt to set the same key.
|
||||
message.Request.Headers.Remove(FoundryFeaturesHeader);
|
||||
message.Request.Headers.Add(FoundryFeaturesHeader, features);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=mcp-toolbox</c> mode.
|
||||
/// The container connects to a public MCP server (the Microsoft Learn MCP endpoint) so tests
|
||||
/// can verify MCP tool discovery and invocation flowing through the Foundry hosted agent.
|
||||
/// </summary>
|
||||
public sealed class McpToolboxHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "mcp-toolbox";
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling-approval</c> mode.
|
||||
/// The container declares an AIFunction tagged <c>RequiresApproval=true</c> so tests can exercise
|
||||
/// the human in the loop approval flow (request, grant, deny).
|
||||
/// </summary>
|
||||
public sealed class ToolCallingApprovalHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "tool-calling-approval";
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling</c> mode.
|
||||
/// The container declares one or more deterministic AIFunctions on the server side
|
||||
/// (e.g. <c>GetUtcNow</c>, <c>Multiply(int,int)</c>) so tests can verify tool invocation behavior
|
||||
/// without requiring approvals.
|
||||
/// </summary>
|
||||
public sealed class ToolCallingHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "tool-calling";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
|
||||
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
|
||||
/// that the model can invoke those tools and that client side toolbox additions surface alongside
|
||||
/// server side registrations when listed.
|
||||
/// </summary>
|
||||
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "toolbox";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Constrained to net10.0: Microsoft.Agents.AI.Foundry.Hosting targets net8/9/10 only
|
||||
(no net472 — depends on ASP.NET Core), while AgentConformance.IntegrationTests
|
||||
inherits the default tests TFM list (net10.0;net472). The intersection is net10.0.
|
||||
-->
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);CS8793;NU1605;NU1903;AAIP001</NoWarn>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Round trip and conversation oriented integration tests against a hosted Responses agent.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class HappyPathHostedAgentTests(HappyPathHostedAgentFixture fixture) : IClassFixture<HappyPathHostedAgentFixture>
|
||||
{
|
||||
private readonly HappyPathHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNonEmptyTextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with a short greeting.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_YieldsAtLeastOneUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var collected = new System.Collections.Generic.List<string>();
|
||||
await foreach (var update in agent.RunStreamingAsync("Reply with a short greeting."))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
collected.Add(update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(collected);
|
||||
Assert.False(string.IsNullOrWhiteSpace(string.Concat(collected)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTurn_WithPreviousResponseId_PreservesContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("My favorite number is 42. Acknowledge briefly.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("What number did I just tell you?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("42", second.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task MultiTurn_WithConversationId_PreservesContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var conversationId = await this._fixture.CreateConversationAsync();
|
||||
try
|
||||
{
|
||||
var options = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("My favorite color is teal. Acknowledge briefly.", options: options);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("What color did I just tell you?", options: options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("teal", second.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoredFalse_Baseline_DoesNotPersistResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var options = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with the word 'pong'.", options: options);
|
||||
|
||||
// Assert: response returned but the response id is not retrievable from the chain.
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
var responseId = response.ResponseId;
|
||||
Assert.False(string.IsNullOrWhiteSpace(responseId));
|
||||
|
||||
// Attempting to fetch the response should fail because nothing was stored.
|
||||
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => responsesClient.GetResponseAsync(responseId));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task StoredFalse_WithPreviousResponseId_ReadsHistoryButDoesNotAppendAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Turn 1 is stored so the chain head exists.
|
||||
var first = await agent.RunAsync("Remember the number 73. Acknowledge briefly.", session);
|
||||
|
||||
// Turn 2 is stored=false but reads from turn 1 via the same session.
|
||||
var optionsNoStore = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
|
||||
});
|
||||
|
||||
// Act
|
||||
var second = await agent.RunAsync("What number did I just tell you?", session, optionsNoStore);
|
||||
|
||||
// Assert: model received history (knows the number) but the new response is not persisted.
|
||||
Assert.Contains("73", second.Text);
|
||||
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => responsesClient.GetResponseAsync(second.ResponseId!));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var conversationId = await this._fixture.CreateConversationAsync();
|
||||
try
|
||||
{
|
||||
var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
|
||||
var notStored = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
|
||||
});
|
||||
|
||||
// Turn 1 stored, populates the conversation.
|
||||
await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored);
|
||||
var beforeCount = await this._fixture.CountConversationItemsAsync(conversationId);
|
||||
|
||||
// Act: turn 2 reads from conversation but is not appended.
|
||||
var second = await agent.RunAsync("What number did I just tell you?", options: notStored);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("99", second.Text);
|
||||
var afterCount = await this._fixture.CountConversationItemsAsync(conversationId);
|
||||
Assert.Equal(beforeCount, afterCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task StoredTrue_Default_PersistsResponseInChainAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with the word 'ack'.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
var fetched = await responsesClient.GetResponseAsync(response.ResponseId!);
|
||||
Assert.NotNull(fetched.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Instructions_FromContainerDefinition_AreObeyedAsync()
|
||||
{
|
||||
// Arrange: the container side instructions for happy-path enforce a single word reply
|
||||
// (e.g. "Always reply with exactly the single word ECHO."). See TestContainer/Program.cs.
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Say something useful.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("ECHO", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for an MCP backed toolbox: the hosted container connects to a public MCP server
|
||||
/// (the Microsoft Learn MCP endpoint) at startup and exposes its tools to the model.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class McpToolboxHostedAgentTests(McpToolboxHostedAgentFixture fixture)
|
||||
: IClassFixture<McpToolboxHostedAgentFixture>
|
||||
{
|
||||
private readonly McpToolboxHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task McpTool_IsInvokedSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Use the Microsoft Learn MCP tool to look up 'Azure AI Foundry'. Reply with one short paragraph.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
|
||||
"Expected at least one MCP tool invocation in the response messages.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task McpTool_WithStructuredArguments_ReturnsValidResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Use the MCP search tool with the query 'agent framework hosted agents'. Reply with at least one fact.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task McpTool_ProducesUsableResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Tell me one thing about Microsoft Foundry that would only be in MS Learn docs.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
# Foundry.Hosting.IntegrationTests
|
||||
|
||||
Integration tests for `Microsoft.Agents.AI.Foundry.Hosting` against real Foundry hosted agents.
|
||||
|
||||
## How it works
|
||||
|
||||
Each test class is bound to a scenario fixture (e.g. `HappyPathHostedAgentFixture`,
|
||||
`ToolCallingHostedAgentFixture`). On `InitializeAsync` the fixture:
|
||||
|
||||
1. Reads `AZURE_AI_PROJECT_ENDPOINT` and `IT_HOSTED_AGENT_IMAGE` from the environment.
|
||||
2. Targets a stable, scenario keyed agent name (e.g. `it-happy-path`). The agent is
|
||||
provisioned out of band by `scripts/it-bootstrap-agents.ps1`; tests only manage versions.
|
||||
3. Calls `AgentAdministrationClient.CreateAgentVersionAsync` with a `HostedAgentDefinition`
|
||||
that points at the image, sets `IT_SCENARIO=<scenario>` in the container env vars, and
|
||||
adds a per-run `IT_RUN_ID` so each run gets a fresh content-addressed version (Foundry
|
||||
deduplicates versions by definition hash).
|
||||
4. Polls until the agent reports `AgentVersionStatus.Active` (timeout: 5 minutes).
|
||||
5. Patches the agent endpoint with `AgentEndpointConfig` (Responses protocol, version
|
||||
selector pointing 100% at the new version).
|
||||
6. Builds a per-agent `ProjectOpenAIClient` with `AgentName` set on the options (this
|
||||
selects the `/agents/{name}/endpoint/protocols/openai` URL suffix; the cached
|
||||
`projectClient.ProjectOpenAIClient` cannot serve a hosted agent), wraps the
|
||||
`ProjectResponsesClient` as an `AIAgent`, and exposes it via `Agent`.
|
||||
|
||||
On `DisposeAsync` only the version created by this fixture is deleted. The agent itself
|
||||
is intentionally never deleted, because its managed identity must hold the pre-granted
|
||||
`Azure AI User` role on the project scope for inbound inference to succeed.
|
||||
|
||||
The container image is **the same for every scenario**. The `IT_SCENARIO` env var, set on
|
||||
the agent definition by each fixture, drives a `switch` in the test container's
|
||||
`Program.cs` to wire up the scenario specific behavior (tools, toolbox, custom storage,
|
||||
etc.).
|
||||
|
||||
## Required environment variables
|
||||
|
||||
| Variable | Source | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
|
||||
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
|
||||
|
||||
## One-time bootstrap (per Foundry project)
|
||||
|
||||
Hosted agent invocation requires the agent's own managed identity to hold the
|
||||
`Azure AI User` role on the project scope. Because each agent's MI is created when the
|
||||
agent is first provisioned (and recycled on agent delete), the bootstrap creates the
|
||||
six stable scenario agents once and grants the role to each MI. The fixture then only
|
||||
manages versions under those existing agents, so the role grants survive across runs.
|
||||
|
||||
```powershell
|
||||
./scripts/it-bootstrap-agents.ps1 `
|
||||
-ProjectEndpoint "https://<account>.services.ai.azure.com/api/projects/<project>" `
|
||||
-Image "<acr>.azurecr.io/foundry-hosting-it:<tag>"
|
||||
```
|
||||
|
||||
The script is idempotent. It requires Owner or User Access Administrator on the project
|
||||
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
|
||||
running the tests.
|
||||
|
||||
## Building and pushing the test container image
|
||||
|
||||
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
|
||||
Build and push it with:
|
||||
|
||||
```powershell
|
||||
$env:IT_REGISTRY = "<your-acr>.azurecr.io"
|
||||
$env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
|
||||
```
|
||||
|
||||
The script tags the image by content hash of the test container source. If you didn't
|
||||
change anything since the last build, the push is a no op.
|
||||
|
||||
The Foundry project's account MI and project MI both need `AcrPull` on the registry.
|
||||
|
||||
## Running the tests locally
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
|
||||
# IT_HOSTED_AGENT_IMAGE was set above.
|
||||
|
||||
dotnet test dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
|
||||
```
|
||||
|
||||
> **Note:** all tests are currently tagged `[Fact(Skip = ...)]` until end to end smoke
|
||||
> verification has run against a live Foundry deployment. Once a scenario has been
|
||||
> exercised and the assertions stabilized, remove the Skip annotation on its tests.
|
||||
|
||||
All test classes carry `[Trait("Category", "FoundryHostedAgents")]` so the CI workflow can
|
||||
route them to a separate Foundry project than the rest of the integration tests (see
|
||||
`.github/workflows/dotnet-build-and-test.yml`).
|
||||
|
||||
## CI wiring
|
||||
|
||||
The main "Run Integration Tests" step excludes this category. Two extra steps run only on
|
||||
`ubuntu-latest` for this category, gated on `paths-filter.outputs.foundryHostingChanges`
|
||||
so they execute only when the project under test, its dependency chain, the test
|
||||
container, the test fixture, or their tooling changed:
|
||||
|
||||
1. **Build and push Foundry Hosted Agents test container** invokes
|
||||
`scripts/it-build-image.ps1` against `vars.IT_HOSTED_AGENT_REGISTRY`. The image is
|
||||
rebuilt every IT run; its tag is content-hashed across the test container source AND
|
||||
its referenced framework projects (`Microsoft.Agents.AI.Foundry.Hosting`,
|
||||
`Microsoft.Agents.AI.Foundry`, `Microsoft.Agents.AI`, `Microsoft.Agents.AI.Abstractions`),
|
||||
so unchanged content is a `docker push` no-op while any framework code change forces
|
||||
a fresh image. The script pipes its `IT_HOSTED_AGENT_IMAGE=<tag>` line into
|
||||
`$GITHUB_ENV` for the next step.
|
||||
|
||||
2. **Run Foundry Hosted Agents Integration Tests** executes only `--filter-trait
|
||||
"Category=FoundryHostedAgents"` with the env vars below mapped onto the names the
|
||||
fixture reads. `IT_HOSTED_AGENT_IMAGE` is the value just exported by step 1.
|
||||
|
||||
| GitHub env var | Mapped to |
|
||||
| --- | --- |
|
||||
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
|
||||
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
|
||||
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
|
||||
|
||||
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
|
||||
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
|
||||
job in `.github/workflows/dotnet-build-and-test.yml` under `filters.foundryHosting` and
|
||||
must stay in sync with `$hashedDirs` in `scripts/it-build-image.ps1`.
|
||||
|
||||
The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
|
||||
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
|
||||
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
|
||||
|
||||
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
|
||||
human-only operation; CI only adds and deletes versions under existing agents.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| Fixture | `IT_SCENARIO` | Agent name | What it tests |
|
||||
| --- | --- | --- | --- |
|
||||
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. |
|
||||
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
|
||||
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
|
||||
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
|
||||
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
|
||||
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
|
||||
|
||||
The placeholder scenarios will be wired up in the test container `Program.cs` once the
|
||||
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the human in the loop tool approval flow: the container declares an AIFunction
|
||||
/// flagged as requiring approval, and the model raises a <see cref="ToolApprovalRequestContent"/>
|
||||
/// before the tool executes.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolCallingApprovalHostedAgentTests(ToolCallingApprovalHostedAgentFixture fixture)
|
||||
: IClassFixture<ToolCallingApprovalHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolCallingApprovalHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ApprovalRequiredTool_RaisesApprovalRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Run the SendEmail tool with subject='hi' to test@example.com.");
|
||||
|
||||
// Assert
|
||||
var approvalRequest = response.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(approvalRequest);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ApprovalGranted_ToolRunsAndResponseReflectsResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
var first = await agent.RunAsync("Run the SendEmail tool with subject='ok' to test@example.com.", session);
|
||||
var approvalRequest = first.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.First();
|
||||
|
||||
var approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
|
||||
|
||||
// Act
|
||||
var second = await agent.RunAsync([followUp], session);
|
||||
|
||||
// Assert: model received the tool result and produced a final response.
|
||||
Assert.False(string.IsNullOrWhiteSpace(second.Text));
|
||||
var hasFurtherApprovalRequest = second.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.Any();
|
||||
Assert.False(hasFurtherApprovalRequest, "Did not expect another approval request after granting.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ApprovalDenied_ToolDoesNotRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
var first = await agent.RunAsync("Run the SendEmail tool with subject='no' to test@example.com.", session);
|
||||
var approvalRequest = first.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.First();
|
||||
|
||||
var approvalResponse = approvalRequest.CreateResponse(approved: false);
|
||||
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
|
||||
|
||||
// Act
|
||||
var second = await agent.RunAsync([followUp], session);
|
||||
|
||||
// Assert: no FunctionResultContent for SendEmail in the response.
|
||||
Assert.False(string.IsNullOrWhiteSpace(second.Text));
|
||||
var sendEmailResults = second.Messages
|
||||
.SelectMany(m => m.Contents.OfType<FunctionResultContent>())
|
||||
.Where(r => r.CallId == approvalRequest.ToolCall?.CallId);
|
||||
Assert.Empty(sendEmailResults);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that exercise server side tool invocation by a hosted agent. The container
|
||||
/// declares deterministic AIFunctions (e.g. <c>GetUtcNow</c>, <c>Multiply</c>) and the
|
||||
/// model decides whether to call them based on the prompt.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolCallingHostedAgentTests(ToolCallingHostedAgentFixture fixture) : IClassFixture<ToolCallingHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolCallingHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_IsInvokedWhenPromptedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("What is the current UTC date and time? Use the GetUtcNow tool.");
|
||||
|
||||
// Assert: response references a timestamp (very loose check; deterministic-ish).
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
|
||||
"Expected at least one FunctionCallContent in the response messages.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_NotInvokedWhenNotNeededAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Say hello in one word.");
|
||||
|
||||
// Assert: no tool call expected for a simple greeting.
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
var toolCallCount = response.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()).Count();
|
||||
Assert.Equal(0, toolCallCount);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_MultiTurn_RemembersPriorToolResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("Multiply 6 by 7 using the Multiply tool. Reply with the result.", session);
|
||||
Assert.Contains("42", first.Text);
|
||||
|
||||
var second = await agent.RunAsync("What was the result of the last multiplication?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("42", second.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_WithArguments_ReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Use the Multiply tool with a=12 and b=11. Reply with just the numeric result.");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("132", response.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Foundry toolbox: the hosted container registers tools via the toolbox API
|
||||
/// (server side), and tests can also add tools client side. The model should be able to
|
||||
/// invoke tools from both sources.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolboxHostedAgentTests(ToolboxHostedAgentFixture fixture) : IClassFixture<ToolboxHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolboxHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerRegisteredToolboxTool_IsCallableAsync()
|
||||
{
|
||||
// Arrange: the container side toolbox registers GetEnvironmentName which returns a constant.
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Call GetEnvironmentName via the toolbox and reply with just the value.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("integration-test", response.Text, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ClientSideAddedToolboxTool_IsListedAndCallableAsync()
|
||||
{
|
||||
// TODO: requires AgentToolboxes API surface. Placeholder asserting the test runs.
|
||||
var agent = this._fixture.Agent;
|
||||
var response = await agent.RunAsync("List all tools you have access to.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ListingTools_ReturnsBothServerAndClientSideEntriesAsync()
|
||||
{
|
||||
// TODO: requires AgentAdministrationClient toolbox listing. Placeholder.
|
||||
var agent = this._fixture.Agent;
|
||||
var response = await agent.RunAsync("Briefly describe what tools are available.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-time bootstrap of stable hosted agents for the Foundry.Hosting.IntegrationTests suite.
|
||||
|
||||
.DESCRIPTION
|
||||
The IT fixture targets stable, scenario-keyed agent names (e.g. it-happy-path) and only
|
||||
manages versions on each test run. The agent itself must already exist AND its managed
|
||||
identity must hold the Azure AI User role on the project scope, otherwise inbound
|
||||
inference calls fail with HTTP 500 PermissionDenied.
|
||||
|
||||
This script idempotently creates each scenario agent (with a placeholder version) and
|
||||
grants Azure AI User on the project to its managed identity. Re-run it safely; existing
|
||||
agents and role assignments are left in place.
|
||||
|
||||
.PARAMETER ProjectEndpoint
|
||||
Foundry project endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>
|
||||
|
||||
.PARAMETER Image
|
||||
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
|
||||
Use the value emitted by scripts/it-build-image.ps1.
|
||||
|
||||
.EXAMPLE
|
||||
./it-bootstrap-agents.ps1 `
|
||||
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
|
||||
-Image "myacr.azurecr.io/foundry-hosting-it:abc123"
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $ProjectEndpoint,
|
||||
[Parameter(Mandatory)] [string] $Image
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Scenarios = @(
|
||||
'happy-path',
|
||||
'tool-calling',
|
||||
'tool-calling-approval',
|
||||
'toolbox',
|
||||
'mcp-toolbox',
|
||||
'custom-storage'
|
||||
)
|
||||
|
||||
# Resolve project ARM scope from the endpoint.
|
||||
$endpointUri = [Uri]$ProjectEndpoint
|
||||
$accountName = $endpointUri.Host.Split('.')[0]
|
||||
$projectName = ($endpointUri.AbsolutePath.TrimEnd('/') -split '/')[-1]
|
||||
$accountInfo = az cognitiveservices account list --query "[?name=='$accountName'].{name:name, rg:resourceGroup, sub:id}" | ConvertFrom-Json
|
||||
if (-not $accountInfo) { throw "Could not find Cognitive Services account '$accountName'." }
|
||||
$rg = $accountInfo[0].rg
|
||||
$sub = ($accountInfo[0].sub -split '/')[2]
|
||||
$projectScope = "/subscriptions/$sub/resourceGroups/$rg/providers/Microsoft.CognitiveServices/accounts/$accountName/projects/$projectName"
|
||||
Write-Host "Project scope: $projectScope"
|
||||
|
||||
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
|
||||
$headers = @{
|
||||
Authorization = "Bearer $tok"
|
||||
'Foundry-Features' = 'HostedAgents=V1Preview'
|
||||
'Content-Type' = 'application/json'
|
||||
}
|
||||
|
||||
foreach ($scenario in $Scenarios) {
|
||||
$agentName = "it-$scenario"
|
||||
Write-Host ""
|
||||
Write-Host "=== $agentName ==="
|
||||
|
||||
# 1. Ensure the agent exists. Create a placeholder version if it doesn't.
|
||||
$agent = $null
|
||||
try {
|
||||
$agent = Invoke-RestMethod -Method GET -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
|
||||
Write-Host " agent exists"
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode -ne 404) { throw }
|
||||
}
|
||||
|
||||
if (-not $agent) {
|
||||
Write-Host " creating placeholder version..."
|
||||
$body = @{
|
||||
definition = @{
|
||||
kind = 'hosted'
|
||||
container_protocol_versions = @(@{ protocol = 'responses'; version = '1.0.0' })
|
||||
cpu = '0.25'
|
||||
memory = '0.5Gi'
|
||||
environment_variables = @{ IT_SCENARIO = $scenario }
|
||||
image = $Image
|
||||
}
|
||||
metadata = @{ enableVnextExperience = 'true' }
|
||||
} | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod -Method POST -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName/versions`?api-version=v1" `
|
||||
-Body $body | Out-Null
|
||||
Start-Sleep 5
|
||||
$agent = Invoke-RestMethod -Method GET -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
|
||||
}
|
||||
|
||||
$principalId = $agent.versions.latest.instance_identity.principal_id
|
||||
Write-Host " agent MI: $principalId"
|
||||
|
||||
# 2. PATCH the agent endpoint to route via @latest if not already configured.
|
||||
# Using @latest means each new version added by the IT fixture automatically becomes the
|
||||
# served version, no per-run PATCH needed (which is good because the strongly-typed
|
||||
# PATCH wrapper is alpha-only on Azure.AI.Projects right now).
|
||||
$hasLatestSelector = $agent.agent_endpoint -and `
|
||||
($agent.agent_endpoint.version_selector.version_selection_rules | Where-Object { $_.agent_version -eq '@latest' })
|
||||
if ($hasLatestSelector) {
|
||||
Write-Host " endpoint already routes via @latest"
|
||||
} else {
|
||||
Write-Host " patching endpoint to route via @latest..."
|
||||
$patchBody = @{
|
||||
agent_endpoint = @{
|
||||
version_selector = @{
|
||||
version_selection_rules = @(@{
|
||||
type = 'FixedRatio'
|
||||
agent_version = '@latest'
|
||||
traffic_percentage = 100
|
||||
})
|
||||
}
|
||||
protocols = @('responses')
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod -Method PATCH -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1" `
|
||||
-Body $patchBody | Out-Null
|
||||
}
|
||||
|
||||
# 3. Grant Azure AI User on the project scope to the agent MI (idempotent).
|
||||
$existing = az role assignment list --assignee $principalId --scope $projectScope `
|
||||
--query "[?roleDefinitionName=='Azure AI User']" 2>$null | ConvertFrom-Json
|
||||
if ($existing) {
|
||||
Write-Host " role already assigned"
|
||||
} else {
|
||||
Write-Host " granting Azure AI User..."
|
||||
$maxAttempts = 12
|
||||
$granted = $false
|
||||
for ($i = 1; $i -le $maxAttempts; $i++) {
|
||||
$output = az role assignment create `
|
||||
--assignee-object-id $principalId `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role 'Azure AI User' `
|
||||
--scope $projectScope 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$granted = $true
|
||||
break
|
||||
}
|
||||
if ($output -match 'Cannot find user or service principal in graph') {
|
||||
Write-Host " attempt $i/$maxAttempts : MI not yet in AAD graph, retrying in 15s..."
|
||||
Start-Sleep 15
|
||||
continue
|
||||
}
|
||||
throw "az role assignment failed: $output"
|
||||
}
|
||||
if (-not $granted) {
|
||||
throw "MI '$principalId' did not appear in AAD graph after $maxAttempts attempts."
|
||||
}
|
||||
Write-Host " granted (RBAC propagation may take 1-3 minutes)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Wait ~3 minutes after first-time grants before running the tests."
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds and pushes the Foundry.Hosting.IntegrationTests.TestContainer image to a container registry.
|
||||
|
||||
.DESCRIPTION
|
||||
The integration tests in dotnet/tests/Foundry.Hosting.IntegrationTests provision real
|
||||
Foundry hosted agents that point at a container image. This script builds and pushes that
|
||||
image, then emits the IT_HOSTED_AGENT_IMAGE=... line that the tests read from the
|
||||
environment.
|
||||
|
||||
.PARAMETER Registry
|
||||
The container registry login server, e.g. mycompany.azurecr.io. Required. There is no
|
||||
default because every team and every dev may use a different registry.
|
||||
|
||||
.PARAMETER Repository
|
||||
Image repository name within the registry. Defaults to foundry-hosting-it.
|
||||
|
||||
.PARAMETER TestContainerProject
|
||||
Path to the test container csproj. Defaults to the in repo location.
|
||||
|
||||
.EXAMPLE
|
||||
PS> ./scripts/it-build-image.ps1 -Registry mycompany.azurecr.io
|
||||
IT_HOSTED_AGENT_IMAGE=mycompany.azurecr.io/foundry-hosting-it:abc123def456
|
||||
|
||||
.EXAMPLE
|
||||
Local dev, set the env var directly:
|
||||
PS> $env:IT_REGISTRY = "mycompany.azurecr.io"
|
||||
PS> $env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
|
||||
|
||||
.EXAMPLE
|
||||
CI workflow, assumes IT_REGISTRY is set in the environment:
|
||||
- name: Build IT image
|
||||
run: pwsh ./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Tee-Object -FilePath $env:GITHUB_ENV
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Registry,
|
||||
|
||||
[string] $Repository = "foundry-hosting-it",
|
||||
|
||||
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Resolve to the repo root regardless of the caller's PWD so all relative paths used below
|
||||
# (TestContainerProject, the framework src dirs hashed for the image tag) resolve correctly.
|
||||
# This script lives at <repoRoot>/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/.
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../../../..")).Path
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
|
||||
if (-not (Test-Path $TestContainerProject)) {
|
||||
throw "Test container project not found at '$TestContainerProject' (repo root '$RepoRoot')."
|
||||
}
|
||||
|
||||
# Strip any scheme/trailing slash from the registry, then derive the ACR short name.
|
||||
$Registry = $Registry -replace '^https?://', '' -replace '/+$', ''
|
||||
$registryHost = $Registry.Split('.')[0]
|
||||
if ([string]::IsNullOrWhiteSpace($registryHost)) {
|
||||
throw "Could not derive ACR short name from -Registry '$Registry'."
|
||||
}
|
||||
|
||||
# Hash the test container source content AND the source of all referenced framework projects
|
||||
# so any edit (in TestContainer OR in dotnet/src/Microsoft.Agents.AI.Foundry*/) produces a new
|
||||
# tag. The TestContainer image embeds compiled output of those projects, so a framework code
|
||||
# change must invalidate the tag for `docker push` to publish a new layer; a TestContainer-only
|
||||
# hash silently reused stale images on framework edits.
|
||||
#
|
||||
# Keep this list in sync with the `foundryHosting` paths-filter in
|
||||
# .github/workflows/dotnet-build-and-test.yml so CI gating and image tagging cover the same set.
|
||||
$hashedDirs = @(
|
||||
$TestContainerProject,
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting",
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry",
|
||||
"dotnet/src/Microsoft.Agents.AI",
|
||||
"dotnet/src/Microsoft.Agents.AI.Abstractions",
|
||||
"dotnet/src/Microsoft.Agents.AI.Workflows"
|
||||
)
|
||||
$sourceFiles = @()
|
||||
foreach ($dir in $hashedDirs) {
|
||||
if (Test-Path $dir) {
|
||||
$sourceFiles += @(git -c core.quotepath=false ls-files -- $dir)
|
||||
}
|
||||
}
|
||||
if ($sourceFiles.Count -eq 0) {
|
||||
throw "No tracked files found under any of: $($hashedDirs -join ', ')"
|
||||
}
|
||||
$fileHashes = git hash-object -- $sourceFiles
|
||||
$shaInput = ($fileHashes -join "`n" | git hash-object --stdin).Trim()
|
||||
$tag = $shaInput.Substring(0, 12)
|
||||
$image = "$Registry/$Repository`:$tag"
|
||||
|
||||
Write-Host "Publishing $TestContainerProject ..." -ForegroundColor Cyan
|
||||
$out = Join-Path $TestContainerProject "out"
|
||||
if (Test-Path $out) {
|
||||
Remove-Item -Recurse -Force $out
|
||||
}
|
||||
|
||||
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Write-Host "Building $image ..." -ForegroundColor Cyan
|
||||
docker build -t $image -f (Join-Path $TestContainerProject "Dockerfile") $TestContainerProject | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "docker build failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Write-Host "Pushing $image ..." -ForegroundColor Cyan
|
||||
az acr login -n $registryHost | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "az acr login failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
docker push $image | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "docker push failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
# Emit the env var line for shells / CI consumption.
|
||||
"IT_HOSTED_AGENT_IMAGE=$image"
|
||||
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
+68
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
@@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenAI;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
@@ -134,6 +136,72 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce()
|
||||
{
|
||||
// Arrange: hosted resolution calls TryApplyUserAgent on every request. Without per-instance
|
||||
// dedup, each call would append another policy entry to the shared OpenAIRequestPolicies,
|
||||
// producing unbounded growth on singleton agents (one chat client reused across requests).
|
||||
using var http = new HttpClient(new NoopHandler());
|
||||
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
|
||||
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
}
|
||||
|
||||
// Assert: exactly one HostedAgentUserAgentPolicy entry on the shared OpenAIRequestPolicies.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient()
|
||||
{
|
||||
// Arrange: dedup is per-OpenAIRequestPolicies-instance, not global, so two agents on
|
||||
// different chat clients each get exactly one registration.
|
||||
using var http1 = new HttpClient(new NoopHandler());
|
||||
using var http2 = new HttpClient(new NoopHandler());
|
||||
var client1 = new OpenAIClient(new ApiKeyCredential("k1"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http1) });
|
||||
var client2 = new OpenAIClient(new ApiKeyCredential("k2"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http2) });
|
||||
|
||||
IChatClient cc1 = client1.GetResponsesClient().AsIChatClient();
|
||||
IChatClient cc2 = client2.GetResponsesClient().AsIChatClient();
|
||||
AIAgent a1 = new ChatClientAgent(cc1);
|
||||
AIAgent a2 = new ChatClientAgent(cc2);
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(a1);
|
||||
FoundryHostingExtensions.TryApplyUserAgent(a2);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, EntriesCount(cc1.GetService<OpenAIRequestPolicies>()!));
|
||||
Assert.Equal(1, EntriesCount(cc2.GetService<OpenAIRequestPolicies>()!));
|
||||
}
|
||||
|
||||
private static int EntriesCount(OpenAIRequestPolicies policies)
|
||||
{
|
||||
var field = typeof(OpenAIRequestPolicies).GetField("_entries", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
var array = (Array?)field?.GetValue(policies);
|
||||
return array?.Length ?? -1;
|
||||
}
|
||||
|
||||
private sealed class NoopHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
|
||||
@@ -780,25 +780,33 @@ public class InputConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
|
||||
public void ConvertItemsToMessages_McpApprovalResponse_ThrowsWhenNoMapping()
|
||||
{
|
||||
// Without a recorded ApprovalEntry the converter cannot reconstruct the original
|
||||
// function call faithfully — any placeholder it produced would still fail downstream
|
||||
// (FICC has no tool to invoke; Azure's stored function_call can't pair with the
|
||||
// synthetic id). Fail fast with a clear error instead of continuing into a confusing
|
||||
// HTTP 400 deep inside the agent loop.
|
||||
var wireId = "mcpr_" + new string('a', 50);
|
||||
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([item]);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(wireId, content.RequestId);
|
||||
Assert.True(content.Approved);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => InputConverter.ConvertItemsToMessages([item]));
|
||||
Assert.Contains(wireId, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
|
||||
{
|
||||
const string AfRequestId = "af_request_xyz";
|
||||
const string AfRequestId = "ficc_call_xyz";
|
||||
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
|
||||
ToolApprovalIdMap.Record(
|
||||
stateBag,
|
||||
wireId,
|
||||
AfRequestId,
|
||||
"call_xyz",
|
||||
"issue_refund",
|
||||
"{\"order_id\":123}");
|
||||
|
||||
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);
|
||||
|
||||
@@ -807,6 +815,17 @@ public class InputConverterTests
|
||||
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(AfRequestId, content.RequestId);
|
||||
Assert.False(content.Approved);
|
||||
|
||||
// Verify the original FunctionCallContent is reconstructed losslessly:
|
||||
// - CallId matches the model-issued id (without FICC's "ficc_" prefix), so the
|
||||
// resulting function_call_output pairs with Azure's stored function_call.
|
||||
// - Name matches the original tool, so FICC can invoke the right function on resume.
|
||||
// - Arguments are preserved.
|
||||
var fcc = Assert.IsType<FunctionCallContent>(content.ToolCall);
|
||||
Assert.Equal("call_xyz", fcc.CallId);
|
||||
Assert.Equal("issue_refund", fcc.Name);
|
||||
Assert.NotNull(fcc.Arguments);
|
||||
Assert.Equal(123, ((System.Text.Json.JsonElement)fcc.Arguments!["order_id"]!).GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -828,10 +847,16 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
|
||||
{
|
||||
const string AfRequestId = "af_request_history";
|
||||
const string AfRequestId = "ficc_call_history";
|
||||
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
|
||||
ToolApprovalIdMap.Record(
|
||||
stateBag,
|
||||
wireId,
|
||||
AfRequestId,
|
||||
"call_history",
|
||||
"delete_file",
|
||||
"{\"path\":\"/tmp/x\"}");
|
||||
|
||||
var item = new OutputItemMcpApprovalResponseResource(
|
||||
id: "ar_history_id",
|
||||
@@ -843,6 +868,10 @@ public class InputConverterTests
|
||||
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(AfRequestId, content.RequestId);
|
||||
Assert.True(content.Approved);
|
||||
|
||||
var fcc = Assert.IsType<FunctionCallContent>(content.ToolCall);
|
||||
Assert.Equal("call_history", fcc.CallId);
|
||||
Assert.Equal("delete_file", fcc.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -862,6 +891,28 @@ public class InputConverterTests
|
||||
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolApprovalIdMap_Record_EmptyCallId_IsNoOp()
|
||||
{
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
var wireId = "mcpr_" + new string('d', 50);
|
||||
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, "ficc_x", callId: string.Empty, name: "tool", argumentsJson: "{}");
|
||||
|
||||
Assert.Null(ToolApprovalIdMap.ResolveEntry(stateBag, wireId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolApprovalIdMap_Record_EmptyName_IsNoOp()
|
||||
{
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
var wireId = "mcpr_" + new string('e', 50);
|
||||
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, "ficc_x", callId: "call_xyz", name: string.Empty, argumentsJson: "{}");
|
||||
|
||||
Assert.Null(ToolApprovalIdMap.ResolveEntry(stateBag, wireId));
|
||||
}
|
||||
|
||||
// ── input_file data-URI decoding (TryDecodeTextDataUri) ──
|
||||
|
||||
[Fact]
|
||||
|
||||
+101
-16
@@ -84,7 +84,7 @@ public class OutputConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionCall_EmitsFunctionCallEventsAsync()
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithoutResult_EmitsFunctionCallWireItemAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate
|
||||
@@ -99,10 +99,12 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Should have: FuncAdded, ArgsDelta, ArgsDone, FuncDone, Completed
|
||||
Assert.IsType<ResponseOutputItemAddedEvent>(events[0]);
|
||||
// A lone FunctionCallContent (no paired FunctionResultContent) is the
|
||||
// OpenAI Responses encoding of a HITL request: the caller is expected to
|
||||
// resume with a function_call_output for this call_id.
|
||||
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events for function call, got {events.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -302,6 +304,8 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// FCC closes any in-flight assistant message, then emits its own function_call
|
||||
// wire item. Result: 2 output items (text message + function_call).
|
||||
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseOutputItemDoneEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
@@ -328,7 +332,7 @@ public class OutputConverterTests
|
||||
|
||||
// G-04
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_GeneratesCallIdAsync()
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_DoesNotEmitWireItemAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate
|
||||
@@ -342,12 +346,14 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
Assert.Contains(events, e => e is ResponseOutputItemAddedEvent);
|
||||
// Empty CallId is invalid for the wire format; emission is skipped.
|
||||
Assert.DoesNotContain(events, e => e is ResponseOutputItemAddedEvent);
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// G-05
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCalls_EmitsSeparateBuildersAsync()
|
||||
public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCallsWithoutResults_EachEmitsWireItemAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
@@ -362,7 +368,10 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Each lone FCC surfaces as its own function_call wire item (HITL request shape).
|
||||
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseFunctionCallArgumentsDoneEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// H-02
|
||||
@@ -537,7 +546,7 @@ public class OutputConverterTests
|
||||
|
||||
// K-03
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionResultContent_IsSkippedWithNoEventsAsync()
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionResultWithoutMatchingCall_EmitsFunctionCallOutputAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "result data")] };
|
||||
@@ -548,8 +557,82 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
Assert.Single(events);
|
||||
Assert.IsType<ResponseCompletedEvent>(events[0]);
|
||||
// A FunctionResultContent always emits a function_call_output wire item; pairing
|
||||
// with a function_call (if any) is established by call_id at the wire layer.
|
||||
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
Assert.Single(events.OfType<ResponseOutputItemDoneEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// K-04
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionCallThenResult_EmitsPairedItemsAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary<string, object?> { ["q"] = "weather" })] },
|
||||
new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Issue #5662: function_call and function_call_output must both surface as
|
||||
// wire items so Azure's stored conversation has a paired call+output and
|
||||
// resume via previous_response_id works.
|
||||
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseOutputItemDoneEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// K-05: An FCC with an empty CallId is dropped without disturbing in-flight text.
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionCallEmptyCallIdMidText_PreservesTextBoundaryAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello, ")] },
|
||||
new AgentResponseUpdate { Contents = [new FunctionCallContent(string.Empty, "skipped", new Dictionary<string, object?>())] },
|
||||
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("world!")] },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// The FCC is skipped (no CallId), and because we now validate CallId before
|
||||
// closing the in-flight assistant message, both text deltas land in the same
|
||||
// output item — only one message-added event is emitted.
|
||||
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// K-06: FRC string results are emitted as raw text on the wire (not JSON-quoted).
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsRawTextAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] };
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
|
||||
// String FRC payloads must not be double-encoded — `sunny`, not `"sunny"`.
|
||||
Assert.Equal("sunny", output.Output.ToString());
|
||||
}
|
||||
|
||||
// L-01
|
||||
@@ -666,6 +749,7 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// text(msg_1) → function_call(call_1) → text(msg_2): three output items.
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
}
|
||||
|
||||
@@ -729,6 +813,7 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Three output items: function_call(call_1), text(msg_1), function_call(call_2).
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
}
|
||||
|
||||
@@ -821,9 +906,10 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Should have: 4 workflow actions + 1 function call + 1 text message = 6 output items
|
||||
// Workflow actions: 4. Lone FCC: 1 (function_call wire item).
|
||||
// Text message: 1. Total output items: 6.
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
|
||||
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
@@ -960,11 +1046,10 @@ public class OutputConverterTests
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Workflow actions: invoked triage, completed triage, invoked expert, completed expert = 4
|
||||
// Content items: 1 function call, 1 text message = 2
|
||||
// Total output items: 6
|
||||
// Workflow actions: 4. Lone FCC: 1 (function_call wire item).
|
||||
// Text message: 1. Total output items: 6.
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
|
||||
// Two text deltas for the two streaming chunks
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
|
||||
+2
-2
@@ -185,10 +185,10 @@ public class OutputConverterWorkflowTests
|
||||
}
|
||||
|
||||
// Workflow actions: 4 (2 invoked + 2 completed)
|
||||
// Content: 1 reasoning + 1 function call + 1 text message = 3
|
||||
// Content: 1 reasoning + 1 function_call (lone FCC = HITL request) + 1 text = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
-452
@@ -1,452 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="UserAgentResponsesClient"/> preserves user-supplied client options
|
||||
/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
|
||||
/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
|
||||
/// Covers both the Azure-flavored <see cref="ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/>.
|
||||
/// </summary>
|
||||
public sealed partial class UserAgentResponsesClientTests
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
|
||||
private const string Deployment = "fake-deployment";
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")]
|
||||
private static partial System.Text.RegularExpressions.Regex SupplementRegex();
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(TestEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalSseResponse());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(TestEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient,
|
||||
userAgentApplicationId: "MY_APP_ID",
|
||||
organizationId: "org_xyz",
|
||||
projectId: "proj_abc");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert: retry policy ran (1 + 2 extras = 3 attempts).
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
Assert.Equal(3, retryPolicy.InvocationCount);
|
||||
foreach (var req in handler.Requests)
|
||||
{
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = inner.AsIChatClient(Deployment);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved).
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(OpenAIEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalSseResponse());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(OpenAIEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DeleteResponseAsync")]
|
||||
[InlineData("CancelResponseAsync")]
|
||||
[InlineData("GetInputTokenCountAsync")]
|
||||
[InlineData("CompactResponseAsync")]
|
||||
[InlineData("GetResponseInputItemCollectionPageAsync")]
|
||||
public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method)
|
||||
{
|
||||
// Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that
|
||||
// grabs the underlying ResponsesClient via chat.GetService<ResponsesClient>() and invokes
|
||||
// a non-Create/Get protocol method. This is the regression path: without overriding these,
|
||||
// the wrapper's dummy throwing pipeline would fire.
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var wrapper = new UserAgentResponsesClient(inner);
|
||||
|
||||
// Act
|
||||
switch (method)
|
||||
{
|
||||
case "DeleteResponseAsync":
|
||||
_ = await wrapper.DeleteResponseAsync("resp_1", options: null!);
|
||||
break;
|
||||
case "CancelResponseAsync":
|
||||
_ = await wrapper.CancelResponseAsync("resp_1", options: null!);
|
||||
break;
|
||||
case "GetInputTokenCountAsync":
|
||||
_ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
break;
|
||||
case "CompactResponseAsync":
|
||||
_ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
break;
|
||||
case "GetResponseInputItemCollectionPageAsync":
|
||||
_ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!);
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"Unhandled method: {method}");
|
||||
break;
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync()
|
||||
{
|
||||
// Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message,
|
||||
// so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers.
|
||||
// The policy's Contains-guard must prevent the supplement from appearing twice.
|
||||
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two.
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
foreach (var req in handler.Requests)
|
||||
{
|
||||
int matches = SupplementRegex().Matches(req.UserAgent).Count;
|
||||
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync()
|
||||
{
|
||||
// Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's
|
||||
// OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport).
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
IChatClient chatClient = inner.AsIChatClient(Deployment);
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
// Act: apply twice.
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
|
||||
// Assert: invoking the agent produces exactly ONE outbound request whose UA contains
|
||||
// the supplement EXACTLY ONCE (would be twice if the wrapper were nested).
|
||||
_ = await chatClient.GetResponseAsync("hello");
|
||||
var req = Assert.Single(handler.Requests);
|
||||
int matches = SupplementRegex().Matches(req.UserAgent).Count;
|
||||
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target. Failure here means MEAI internals
|
||||
// changed and the polyfill needs updating.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
|
||||
var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(field);
|
||||
Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType),
|
||||
$"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponsesClient_PipelineProperty_ReflectionGuard()
|
||||
{
|
||||
// The polyfill design assumes ResponsesClient.Pipeline remains accessible.
|
||||
var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.NotNull(pipelineProp);
|
||||
Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType);
|
||||
}
|
||||
|
||||
private static IChatClient MakeWithDelegating(ResponsesClient inner)
|
||||
{
|
||||
IChatClient meai = inner.AsIChatClient(Deployment);
|
||||
var meaiType = meai.GetType();
|
||||
var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
field.SetValue(meai, new UserAgentResponsesClient(inner));
|
||||
return meai;
|
||||
}
|
||||
|
||||
private static ProjectResponsesClient BuildInner(
|
||||
HttpClient httpClient,
|
||||
string? userAgentApplicationId = null,
|
||||
string? organizationId = null,
|
||||
string? projectId = null,
|
||||
PipelinePolicy? retryPolicy = null)
|
||||
{
|
||||
var options = new ProjectResponsesClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
};
|
||||
if (userAgentApplicationId is not null)
|
||||
{
|
||||
options.UserAgentApplicationId = userAgentApplicationId;
|
||||
}
|
||||
if (organizationId is not null)
|
||||
{
|
||||
options.OrganizationId = organizationId;
|
||||
}
|
||||
if (projectId is not null)
|
||||
{
|
||||
options.ProjectId = projectId;
|
||||
}
|
||||
if (retryPolicy is not null)
|
||||
{
|
||||
options.RetryPolicy = retryPolicy;
|
||||
}
|
||||
|
||||
return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options);
|
||||
}
|
||||
|
||||
private static ResponsesClient BuildOpenAIInner(
|
||||
HttpClient httpClient,
|
||||
string? userAgentApplicationId = null)
|
||||
{
|
||||
var options = new OpenAIClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
Endpoint = new Uri(OpenAIEndpoint),
|
||||
};
|
||||
if (userAgentApplicationId is not null)
|
||||
{
|
||||
options.UserAgentApplicationId = userAgentApplicationId;
|
||||
}
|
||||
|
||||
return new ResponsesClient(new ApiKeyCredential("test-key"), options);
|
||||
}
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
private static string MinimalSseResponse()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("event: response.completed\n");
|
||||
sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n");
|
||||
sb.Append("data: [DONE]\n\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RecordingHandler(string body)
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string ua = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: "(none)";
|
||||
this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent);
|
||||
|
||||
private sealed class CountingRetryPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly int _extraAttempts;
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public CountingRetryPolicy(int extraAttempts)
|
||||
{
|
||||
this._extraAttempts = extraAttempts;
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
for (int i = 0; i <= this._extraAttempts; i++)
|
||||
{
|
||||
this.InvocationCount++;
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
for (int i = 0; i <= this._extraAttempts; i++)
|
||||
{
|
||||
this.InvocationCount++;
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the per-call <c>x-client-*</c> header pipeline:
|
||||
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/>,
|
||||
/// <see cref="ClientHeadersExtensions.UseClientHeaders(AIAgentBuilder)"/>,
|
||||
/// the <c>ClientHeadersAgent</c> decorator, the <c>ClientHeadersScope</c> AsyncLocal,
|
||||
/// and the <c>ClientHeadersPolicy</c> stamping policy.
|
||||
/// </summary>
|
||||
public sealed class ClientHeadersExtensionsTests
|
||||
{
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. WithClientHeader writes namespaced key with valid value
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeader_WritesNamespacedKey_WithValidValue()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatOptions();
|
||||
|
||||
// Act
|
||||
options.WithClientHeader("x-client-end-user-id", "alice");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(options.AdditionalProperties);
|
||||
var raw = options.AdditionalProperties[ClientHeadersExtensions.ClientHeadersKey];
|
||||
var dict = Assert.IsType<Dictionary<string, string>>(raw);
|
||||
Assert.Equal("alice", dict["X-CLIENT-END-USER-ID"]); // OrdinalIgnoreCase
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. WithClientHeader rejects non-x-client- prefix
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("Authorization")]
|
||||
[InlineData("X-Custom-Header")]
|
||||
[InlineData("client-end-user-id")]
|
||||
[InlineData("xclient-end-user-id")]
|
||||
public void WithClientHeader_RejectsInvalidPrefix(string name)
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatOptions();
|
||||
|
||||
// Act / Assert
|
||||
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "value"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. WithClientHeader rejects null/empty name and value
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeader_RejectsNullName()
|
||||
{
|
||||
var options = new ChatOptions();
|
||||
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader(null!, "v"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeader_RejectsNullValue()
|
||||
{
|
||||
var options = new ChatOptions();
|
||||
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader("x-client-foo", null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void WithClientHeader_RejectsEmptyOrWhitespaceName(string name)
|
||||
{
|
||||
var options = new ChatOptions();
|
||||
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "v"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeader_RejectsEmptyValue()
|
||||
{
|
||||
var options = new ChatOptions();
|
||||
Assert.Throws<ArgumentException>(() => options.WithClientHeader("x-client-foo", ""));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. WithClientHeaders (bulk) is all-or-nothing on first invalid key
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeaders_AllOrNothing_OnInvalidKey()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatOptions();
|
||||
var headers = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("x-client-end-user-id", "alice"),
|
||||
new KeyValuePair<string, string>("Authorization", "secret"), // invalid prefix
|
||||
new KeyValuePair<string, string>("x-client-end-chat-id", "chat-1"),
|
||||
};
|
||||
|
||||
// Act / Assert: throws, and no entries are written.
|
||||
Assert.Throws<ArgumentException>(() => options.WithClientHeaders(headers));
|
||||
Assert.Null(options.GetClientHeaders());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5. Multiple WithClientHeader calls accumulate (additive)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeader_Accumulates_MultipleCalls()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatOptions();
|
||||
|
||||
// Act
|
||||
options.WithClientHeader("x-client-a", "1");
|
||||
options.WithClientHeader("x-client-b", "2");
|
||||
options.WithClientHeader("x-client-a", "1-updated"); // upsert
|
||||
|
||||
// Assert
|
||||
var dict = options.GetClientHeaders();
|
||||
Assert.NotNull(dict);
|
||||
Assert.Equal(2, dict!.Count);
|
||||
Assert.Equal("1-updated", dict["x-client-a"]);
|
||||
Assert.Equal("2", dict["x-client-b"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 6. Conflict on slot occupied by foreign type throws InvalidOperationException
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void WithClientHeader_ForeignTypeAtSlot_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatOptions
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
[ClientHeadersExtensions.ClientHeadersKey] = "this is not a dictionary",
|
||||
},
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
Assert.Throws<InvalidOperationException>(() => options.WithClientHeader("x-client-foo", "v"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 7. UseClientHeaders is idempotent (already-wired returns innerAgent)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UseClientHeaders_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new FakeAgent();
|
||||
var first = inner.AsBuilder().UseClientHeaders().Build();
|
||||
|
||||
// Act
|
||||
var second = first.AsBuilder().UseClientHeaders().Build();
|
||||
|
||||
// Assert: only one ClientHeadersAgent in the chain.
|
||||
Assert.NotNull(first.GetService<ClientHeadersAgent>());
|
||||
Assert.NotNull(second.GetService<ClientHeadersAgent>());
|
||||
// The second call should return the same agent unchanged because the chain is already wired.
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 8. ClientHeadersAgent snapshots dict at push time (mid-run mutation does not leak)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClientHeadersAgent_SnapshotsAtPush_MidRunMutationDoesNotLeakAsync()
|
||||
{
|
||||
// Arrange: a fake inner agent that exposes ClientHeadersScope.Current at the moment of RunAsync.
|
||||
IReadOnlyDictionary<string, string>? observed = null;
|
||||
var inner = new ProbeAgent(_ =>
|
||||
{
|
||||
observed = ClientHeadersScope.Current;
|
||||
// Mutate the source dictionary mid-run; snapshot must not see the mutation.
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var agent = new ClientHeadersAgent(inner);
|
||||
var chatOptions = new ChatOptions();
|
||||
chatOptions.WithClientHeader("x-client-end-user-id", "alice");
|
||||
|
||||
// Act
|
||||
var task = agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions));
|
||||
// Mutate the source after RunAsync starts.
|
||||
chatOptions.WithClientHeader("x-client-end-user-id", "bob");
|
||||
await task;
|
||||
|
||||
// Assert: probe saw "alice", not "bob".
|
||||
Assert.NotNull(observed);
|
||||
Assert.Equal("alice", observed!["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 9. ClientHeadersAgent streaming keeps scope alive across yields
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClientHeadersAgent_Streaming_HasScopeAtFirstYieldAsync()
|
||||
{
|
||||
// Arrange: in production the SCM pipeline policy fires once at the first MoveNextAsync
|
||||
// (when MEAI's OpenAIResponsesChatClient initiates the HTTP request). We assert that at
|
||||
// that critical moment the AsyncLocal scope is observable. End-to-end coverage of the wire
|
||||
// behavior is provided by EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync.
|
||||
IReadOnlyDictionary<string, string>? observedAtFirstYield = null;
|
||||
var inner = new ProbeStreamingAgent(yields: 1, onYield: () => observedAtFirstYield = ClientHeadersScope.Current);
|
||||
var agent = new ClientHeadersAgent(inner);
|
||||
|
||||
var chatOptions = new ChatOptions();
|
||||
chatOptions.WithClientHeader("x-client-end-user-id", "carol");
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions)))
|
||||
{
|
||||
// drain
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(observedAtFirstYield);
|
||||
Assert.Equal("carol", observedAtFirstYield!["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 10. ClientHeadersScope.Push is LIFO and AsyncLocal-isolated (parallel runs don't leak)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClientHeadersScope_IsLifoAndAsyncLocalIsolatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var dictA = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
|
||||
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
|
||||
|
||||
// Act / Assert
|
||||
await Task.WhenAll(
|
||||
ProbeAsync(dictA, "alice"),
|
||||
ProbeAsync(dictB, "bob"));
|
||||
|
||||
async Task ProbeAsync(Dictionary<string, string> dict, string expected)
|
||||
{
|
||||
using (ClientHeadersScope.Push(dict))
|
||||
{
|
||||
await Task.Yield();
|
||||
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 11. ClientHeadersPolicy no-ops when scope is null
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClientHeadersPolicy_NoOps_WhenScopeIsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
|
||||
perCallPolicies: [ClientHeadersPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act: no scope pushed
|
||||
var msg = pipeline.CreateMessage();
|
||||
msg.Request.Method = "GET";
|
||||
msg.Request.Uri = new Uri("https://example.test/");
|
||||
await pipeline.SendAsync(msg);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain(handler.Headers, kv => kv.Key.StartsWith("x-client-", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 12. ClientHeadersPolicy stamps with Set (overwrites pre-existing same-name header)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClientHeadersPolicy_StampsWithSet_OverwritesPreExistingHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
// A pre-existing policy that always sets x-client-end-user-id=initial.
|
||||
var preExisting = new HeaderSetterPolicy("x-client-end-user-id", "initial");
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
|
||||
perCallPolicies: [preExisting, ClientHeadersPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
var perCall = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
|
||||
|
||||
// Act
|
||||
using (ClientHeadersScope.Push(perCall))
|
||||
{
|
||||
var msg = pipeline.CreateMessage();
|
||||
msg.Request.Method = "GET";
|
||||
msg.Request.Uri = new Uri("https://example.test/");
|
||||
await pipeline.SendAsync(msg);
|
||||
}
|
||||
|
||||
// Assert: the per-call value won.
|
||||
Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 13. Reflection dedup catches duplicate registration on a single OpenAIRequestPolicies
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void OpenAIRequestPoliciesReflection_DedupsDuplicateRegistration()
|
||||
{
|
||||
// Arrange
|
||||
var policies = new OpenAIRequestPolicies();
|
||||
|
||||
// Act
|
||||
var firstAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
|
||||
var secondAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
|
||||
|
||||
// Assert
|
||||
Assert.True(firstAdded);
|
||||
Assert.False(secondAdded);
|
||||
Assert.Equal(1, EntriesCount(policies));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 14. Reflection dedup gracefully fails when shape is wrong (use a fake type to simulate)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void OpenAIRequestPoliciesReflection_ContainsPolicy_ReturnsFalse_OnNullEntries()
|
||||
{
|
||||
// Arrange: a fresh OpenAIRequestPolicies (Entries field exists, but is empty).
|
||||
var policies = new OpenAIRequestPolicies();
|
||||
|
||||
// Act / Assert
|
||||
Assert.False(OpenAIRequestPoliciesReflection.ContainsPolicy(policies, ClientHeadersPolicy.Instance));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 15. CI guardrail: assert OpenAIRequestPolicies._entries field shape
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void OpenAIRequestPolicies_EntriesField_ShapeGuardrail()
|
||||
{
|
||||
// Arrange / Act
|
||||
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
// Assert: this test fails loudly if MEAI renames the field, so we know to update
|
||||
// OpenAIRequestPoliciesReflection. The Entry array element type is private so we only
|
||||
// assert that the field is an Array; the ContainsPolicy method itself reflects the Policy
|
||||
// member dynamically so it survives Entry-shape changes too.
|
||||
Assert.NotNull(field);
|
||||
Assert.True(typeof(Array).IsAssignableFrom(field!.FieldType),
|
||||
$"Expected _entries to be an Array, got {field.FieldType}.");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 16. Foundry hosting end-to-end: per-call x-client-end-user-id reaches the wire
|
||||
// (Covered by the existing HostedOutboundUserAgentTests pattern; we add a focused unit test
|
||||
// here that verifies UseClientHeaders + the OpenAIRequestPolicies bridge stamps headers
|
||||
// on the wire when invoked through a real ChatClientAgent.)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_UseClientHeaders_StampsOnWireAsync()
|
||||
{
|
||||
// Arrange: build a real OpenAI ResponsesClient pointed at a fake handler.
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
|
||||
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
|
||||
var responsesClient = openAIClient.GetResponsesClient();
|
||||
IChatClient chatClient = responsesClient.AsIChatClient();
|
||||
|
||||
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
|
||||
|
||||
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
|
||||
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("hi", options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.True(handler.Requests.Count > 0);
|
||||
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 17. Customer raw end-to-end: covered by #16 (which uses raw new ChatClientAgent + AsBuilder).
|
||||
// Add a streaming variant here.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
|
||||
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
|
||||
var responsesClient = openAIClient.GetResponsesClient();
|
||||
IChatClient chatClient = responsesClient.AsIChatClient();
|
||||
|
||||
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
|
||||
|
||||
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
|
||||
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "carol");
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await foreach (var _ in agent.RunStreamingAsync("hi", options: runOptions))
|
||||
{
|
||||
// drain
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The fake handler returns a non-streaming JSON; MEAI may throw mid-stream while parsing.
|
||||
// The wire request is captured before parsing, so the assertion below still validates the header.
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(handler.Requests.Count > 0);
|
||||
Assert.Equal("carol", handler.Requests[0].Headers["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 18. Headers-set-but-no-bridge: silent no-op confirmed (non-OpenAI mock)
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task UseClientHeaders_OnNonOpenAIClient_IsSilentNoOpAsync()
|
||||
{
|
||||
// Arrange: a non-OpenAI fake agent that does not expose OpenAIRequestPolicies.
|
||||
var inner = new FakeAgent();
|
||||
var agent = inner.AsBuilder().UseClientHeaders().Build();
|
||||
|
||||
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
|
||||
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
|
||||
|
||||
// Act / Assert: no throw. AsyncLocal flows but no policy stamps anything because the
|
||||
// chat client doesn't have OpenAIRequestPolicies registered.
|
||||
await agent.RunAsync("hi", options: runOptions);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 19. Shared IChatClient across two agents both calling UseClientHeaders registers
|
||||
// ClientHeadersPolicy exactly once on the shared OpenAIRequestPolicies.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SharedChatClient_AcrossTwoAgents_RegistersPolicyOnceAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
|
||||
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
|
||||
var responsesClient = openAIClient.GetResponsesClient();
|
||||
IChatClient chatClient = responsesClient.AsIChatClient();
|
||||
|
||||
// Act: build two agents that share the same chat client. Each calls UseClientHeaders.
|
||||
AIAgent agent1 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
|
||||
AIAgent agent2 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
|
||||
|
||||
// Assert: the shared OpenAIRequestPolicies has exactly one ClientHeadersPolicy registered.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
|
||||
// And on the wire, the per-call header is stamped exactly once (no duplication).
|
||||
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
|
||||
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
|
||||
try
|
||||
{
|
||||
await agent1.RunAsync("hi", options: runOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// tolerate parser issues; we assert on the wire.
|
||||
}
|
||||
Assert.True(handler.Requests.Count > 0);
|
||||
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 20. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations
|
||||
// on the same chat client (mirrors the Foundry.Hosting per-request resolution scenario).
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce()
|
||||
{
|
||||
// Arrange: a chat client whose OpenAIRequestPolicies service we can inspect.
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
|
||||
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
|
||||
|
||||
// Act: simulate N hosted-resolution-style wirings on top of the same shared chat client.
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
_ = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
|
||||
}
|
||||
|
||||
// Assert: exactly one ClientHeadersPolicy entry on the shared OpenAIRequestPolicies.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
private static int EntriesCount(OpenAIRequestPolicies policies)
|
||||
{
|
||||
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var array = (Array?)field?.GetValue(policies);
|
||||
return array?.Length ?? -1;
|
||||
}
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>An <see cref="HttpClientHandler"/> that records request headers and returns a fixed response body.</summary>
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
|
||||
public RecordingHandler(string body = """{}""")
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public Dictionary<string, string> Headers => this.Requests.Count > 0 ? this.Requests[0].Headers : new Dictionary<string, string>();
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var h in request.Headers)
|
||||
{
|
||||
headers[h.Key] = string.Join(",", h.Value);
|
||||
}
|
||||
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", headers));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordedRequest
|
||||
{
|
||||
public RecordedRequest(string uri, Dictionary<string, string> headers)
|
||||
{
|
||||
this.Uri = uri;
|
||||
this.Headers = headers;
|
||||
}
|
||||
|
||||
public string Uri { get; }
|
||||
public Dictionary<string, string> Headers { get; }
|
||||
}
|
||||
|
||||
/// <summary>A pipeline policy that always stamps a fixed header value via Headers.Set.</summary>
|
||||
private sealed class HeaderSetterPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string _value;
|
||||
|
||||
public HeaderSetterPolicy(string name, string value)
|
||||
{
|
||||
this._name = name;
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A trivial session used by fake agents in these tests.</summary>
|
||||
private sealed class TrivialSession : AgentSession { }
|
||||
|
||||
/// <summary>A minimal AIAgent that does nothing; used to test decorator wiring.</summary>
|
||||
private sealed class FakeAgent : AIAgent
|
||||
{
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new AgentResponse());
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new TrivialSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
|
||||
new(JsonDocument.Parse("{}").RootElement);
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
|
||||
new(new TrivialSession());
|
||||
}
|
||||
|
||||
/// <summary>An AIAgent that invokes a probe action each time RunAsync is called.</summary>
|
||||
private sealed class ProbeAgent : AIAgent
|
||||
{
|
||||
private readonly Func<CancellationToken, Task> _probe;
|
||||
|
||||
public ProbeAgent(Func<CancellationToken, Task> probe)
|
||||
{
|
||||
this._probe = probe;
|
||||
}
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this._probe(cancellationToken);
|
||||
return new AgentResponse();
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this._probe(cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new TrivialSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
|
||||
new(JsonDocument.Parse("{}").RootElement);
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
|
||||
new(new TrivialSession());
|
||||
}
|
||||
|
||||
/// <summary>An AIAgent whose streaming method invokes <c>onYield</c> at each yield point.</summary>
|
||||
private sealed class ProbeStreamingAgent : AIAgent
|
||||
{
|
||||
private readonly int _yields;
|
||||
private readonly Action _onYield;
|
||||
|
||||
public ProbeStreamingAgent(int yields, Action onYield)
|
||||
{
|
||||
this._yields = yields;
|
||||
this._onYield = onYield;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new AgentResponse());
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
for (int i = 0; i < this._yields; i++)
|
||||
{
|
||||
this._onYield();
|
||||
await Task.Yield();
|
||||
yield return new AgentResponseUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new TrivialSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
|
||||
new(JsonDocument.Parse("{}").RootElement);
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
|
||||
new(new TrivialSession());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -153,6 +154,48 @@ public class FoundryAgentTests
|
||||
Assert.NotNull(innerAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PreWiresClientHeadersAgent()
|
||||
{
|
||||
// Arrange / Act: the public FoundryAgent ctor should pre-wire the client-headers
|
||||
// pipeline so x-client-* headers stamped on ChatClientAgentRunOptions reach the wire.
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Test");
|
||||
|
||||
// Assert: ClientHeadersAgent decorator is present in the delegating chain.
|
||||
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent()
|
||||
{
|
||||
// Arrange: stand up a real AIProjectClient pointed at a fake transport.
|
||||
using var handler = new NoopHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var http = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
|
||||
|
||||
// Act: this AsAIAgent path constructs FoundryAgent via its internal
|
||||
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
|
||||
var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
|
||||
}
|
||||
|
||||
private sealed class NoopHandler : HttpClientHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_ReturnsIChatClient()
|
||||
{
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
<Compile Remove="FoundryEvalsTests.cs" />
|
||||
<Compile Remove="ClientHeadersExtensionsTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -69,6 +69,69 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Equal("A quoted description", skills[0].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_BlockScalarDescription_ParsesMultilineValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "block-scalar-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: block-scalar-skill\ndescription: |\n This is a multiline\n description for the skill.\n---\nBody text.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("This is a multiline\ndescription for the skill.", skills[0].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_FoldedScalarDescription_ParsesMultilineValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "folded-scalar-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: folded-scalar-skill\ndescription: >\n This is a multiline\n description for the skill.\n---\nBody text.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal("This is a multiline description for the skill.", skills[0].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("|-", "This is a multiline\ndescription for the skill.")]
|
||||
[InlineData("|+", "This is a multiline\ndescription for the skill.\n")]
|
||||
[InlineData(">-", "This is a multiline description for the skill.")]
|
||||
[InlineData(">+", "This is a multiline description for the skill.\n")]
|
||||
public async Task GetSkillsAsync_ScalarDescriptionWithChompingIndicator_ParsesValueAsync(string indicator, string expectedDescription)
|
||||
{
|
||||
// Arrange
|
||||
string chomping = indicator[1] == '+' ? "keep" : "strip";
|
||||
string skillName = "chomping-scalar-skill-" + (indicator[0] == '|' ? "literal-" : "folded-") + chomping;
|
||||
string skillDir = Path.Combine(this._testRoot, skillName);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {skillName}\ndescription: {indicator}\n This is a multiline\n description for the skill.\n---\nBody text.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Equal(expectedDescription, skills[0].Frontmatter.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync()
|
||||
{
|
||||
|
||||
@@ -577,7 +577,8 @@ public class OpenTelemetryAgentTests
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "web_search"
|
||||
"type": "web_search",
|
||||
"name": "web_search"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
@@ -604,43 +605,21 @@ public class OpenTelemetryAgentTests
|
||||
Assert.False(tags.ContainsKey("gen_ai.output.messages"));
|
||||
Assert.False(tags.ContainsKey("gen_ai.system_instructions"));
|
||||
|
||||
// gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+)
|
||||
// gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+).
|
||||
// ME.AI 10.5.1 omits description/parameters for function tools when sensitive data is disabled.
|
||||
Assert.Equal(ReplaceWhitespace("""
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "GetPersonAge",
|
||||
"description": "Gets the age of a person by name.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"personName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"personName"
|
||||
]
|
||||
}
|
||||
"name": "GetPersonAge"
|
||||
},
|
||||
{
|
||||
"type": "web_search"
|
||||
"type": "web_search",
|
||||
"name": "web_search"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "GetCurrentWeather",
|
||||
"description": "Gets the current weather for a location.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location"
|
||||
]
|
||||
}
|
||||
"name": "GetCurrentWeather"
|
||||
}
|
||||
]
|
||||
"""), ReplaceWhitespace(tags["gen_ai.tool.definitions"]));
|
||||
|
||||
+70
-11
@@ -4,14 +4,19 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
|
||||
|
||||
@@ -48,9 +53,9 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
|
||||
#region InvokeHttpRequest Tests
|
||||
|
||||
[RetryTheory(3, 5000)]
|
||||
[InlineData("HttpRequest.yaml", "visibility: public")]
|
||||
public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) =>
|
||||
this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains);
|
||||
[InlineData("HttpRequest.yaml")]
|
||||
public Task ValidateHttpRequestAsync(string workflowFileName) =>
|
||||
this.RunHttpRequestTestAsync(workflowFileName);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -261,16 +266,65 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
|
||||
|
||||
#region InvokeHttpRequest Test Helpers
|
||||
|
||||
/// <summary>
|
||||
/// The Azure ARM scope used to acquire bearer tokens for the HttpRequestAction
|
||||
/// integration test. Matches the URL configured in <c>HttpRequest.yaml</c>.
|
||||
/// </summary>
|
||||
private const string ArmScope = "https://management.azure.com/.default";
|
||||
|
||||
/// <summary>
|
||||
/// The expected ARM endpoint. Only requests whose absolute URL exactly matches
|
||||
/// this scheme and host receive the authenticated <see cref="HttpClient"/>; all
|
||||
/// other URLs (including subdomain look-alikes such as
|
||||
/// <c>https://management.azure.com.evil.com</c>) fall through to the handler
|
||||
/// default and never see the bearer token.
|
||||
/// </summary>
|
||||
private static readonly Uri s_armEndpoint = new("https://management.azure.com/");
|
||||
|
||||
/// <summary>
|
||||
/// Runs an HttpRequestAction workflow test with the specified configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The workflow under test calls an authenticated Azure ARM endpoint. We acquire a
|
||||
/// single bearer token via the same Azure CLI credential used elsewhere in the
|
||||
/// integration test suite, attach it to a cached <see cref="HttpClient"/>, and route
|
||||
/// matching requests through that client via <see cref="DefaultHttpRequestHandler"/>'s
|
||||
/// <c>httpClientProvider</c> callback. The test owns the <see cref="HttpClient"/>'s
|
||||
/// lifetime and disposes it explicitly — <see cref="DefaultHttpRequestHandler"/> does
|
||||
/// not dispose provider-returned clients.
|
||||
/// </remarks>
|
||||
private async Task RunHttpRequestTestAsync(
|
||||
string workflowFileName,
|
||||
string? expectedResultContains = null)
|
||||
string workflowFileName)
|
||||
{
|
||||
// Arrange
|
||||
string workflowPath = GetWorkflowPath(workflowFileName);
|
||||
await using DefaultHttpRequestHandler httpRequestHandler = new();
|
||||
|
||||
AccessToken accessToken =
|
||||
await TestAzureCliCredentials
|
||||
.CreateAzureCliCredential()
|
||||
.GetTokenAsync(new TokenRequestContext([ArmScope]), CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using HttpClient authenticatedClient = new();
|
||||
authenticatedClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", accessToken.Token);
|
||||
|
||||
await using DefaultHttpRequestHandler httpRequestHandler =
|
||||
new(httpClientProvider: (request, _) =>
|
||||
{
|
||||
if (Uri.TryCreate(request.Url, UriKind.Absolute, out Uri? requestUri) &&
|
||||
string.Equals(requestUri.Scheme, s_armEndpoint.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(requestUri.Host, s_armEndpoint.Host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
#pragma warning disable CA2025 // authenticatedClient outlives the handler (LIFO using disposal) and the workflow awaits all dispatches.
|
||||
return Task.FromResult<HttpClient?>(authenticatedClient);
|
||||
#pragma warning restore CA2025
|
||||
}
|
||||
|
||||
// Fall back to the handler's internal client for any non-ARM URLs.
|
||||
return Task.FromResult<HttpClient?>(null);
|
||||
});
|
||||
|
||||
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
|
||||
externalConversation: false,
|
||||
httpRequestHandler: httpRequestHandler);
|
||||
@@ -284,11 +338,16 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
|
||||
// Assert - Verify executor and action events
|
||||
AssertWorkflowEventsEmitted(workflowEvents);
|
||||
|
||||
// Assert - Verify expected result if specified
|
||||
if (expectedResultContains is not null)
|
||||
{
|
||||
AssertResultContains(workflowEvents, expectedResultContains);
|
||||
}
|
||||
MessageActivityEvent? messageEvent = workflowEvents.Events
|
||||
.OfType<MessageActivityEvent>()
|
||||
.LastOrDefault();
|
||||
|
||||
Assert.NotNull(messageEvent);
|
||||
Assert.NotNull(messageEvent.Message);
|
||||
Assert.True(
|
||||
Guid.TryParse(messageEvent.Message, out Guid retrievedTenantId),
|
||||
$"Expected the SendMessage payload to be a tenant GUID, but got: '{messageEvent.Message}'");
|
||||
Assert.NotEqual(Guid.Empty, retrievedTenantId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+15
-16
@@ -1,6 +1,10 @@
|
||||
#
|
||||
# This workflow tests invoking HttpRequestAction end-to-end.
|
||||
# Uses the public GitHub API (unauthenticated) to fetch repo metadata.
|
||||
# Uses the Azure ARM tenants endpoint, which is authenticated, fully static, and
|
||||
# reachable with the credentials the integration test pipeline already provides
|
||||
# (via az login). The bearer token is supplied by the test through a custom
|
||||
# HttpClient passed to DefaultHttpRequestHandler; the YAML deliberately does not
|
||||
# carry an Authorization header.
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
@@ -9,24 +13,19 @@ trigger:
|
||||
id: workflow_http_request_test
|
||||
actions:
|
||||
|
||||
# Set the repo owner used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_owner
|
||||
variable: Local.RepoOwner
|
||||
value: dotnet
|
||||
|
||||
# Invoke the GitHub repo API.
|
||||
# Invoke the Azure ARM tenants list API.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
id: fetch_tenants
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
|
||||
url: https://management.azure.com/tenants?api-version=2022-09-01
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-integration-test
|
||||
response: Local.RepoInfo
|
||||
Accept: application/json
|
||||
response: Local.TenantsResponse
|
||||
|
||||
# Surface the Repo visibility field from the parsed JSON response.
|
||||
# Surface the first tenant id from the parsed JSON response. Every
|
||||
# authenticated principal belongs to at least one tenant, so this path
|
||||
# always resolves on a successful call.
|
||||
- kind: SendMessage
|
||||
id: show_visibility
|
||||
message: "visibility: {Local.RepoInfo.visibility}"
|
||||
id: show_first_tenant
|
||||
message: "{First(Local.TenantsResponse.value).tenantId}"
|
||||
|
||||
Reference in New Issue
Block a user