Merge branch 'main' into dev/dotnet_workflow/magentic

This commit is contained in:
Jacob Alber
2026-05-07 07:52:08 -04:00
committed by GitHub
Unverified
129 changed files with 18752 additions and 3710 deletions
@@ -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"]
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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";
}
@@ -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";
}
@@ -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
}
@@ -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]
@@ -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]);
@@ -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]);
}
@@ -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()
{
@@ -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"]));
@@ -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
@@ -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}"