chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,84 @@
// 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>
/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
/// The container uses <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> with two
/// Contoso Outdoors skills (support-style, escalation-policy) to verify the progressive
/// disclosure flow: skills are advertised in the system prompt and loaded on demand via
/// the <c>load_skill</c> tool when the model decides they are relevant.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture<AgentSkillsHostedAgentFixture>
{
private readonly AgentSkillsHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task RoutineQuestion_LoadsSupportStyleSkillAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask a routine support question that should trigger the support-style skill
var response = await agent.RunAsync(
"Hi, I am Alex. I just want to confirm I can return my tent within 30 days.");
// Assert — response should contain the canary token proving the skill was loaded
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("STYLE-CANARY-3318", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task EscalationTrigger_LoadsEscalationPolicySkillAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — trigger an escalation (legal threat + refund > $500)
var response = await agent.RunAsync(
"I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.");
// Assert — response should contain the escalation canary token
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("ESC-CANARY-7742", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task SkillsAreAdvertised_LoadSkillToolIsAvailableAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask the model what skills are available (triggers system prompt inspection)
var response = await agent.RunAsync(
"List the skills you have access to. Just give me their names.");
// Assert — both skills should be mentioned (they are advertised in the system prompt)
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("support-style", response.Text);
Assert.Contains("escalation-policy", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task LoadSkill_InvokesToolAndReturnsContentAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask a question that should load a specific skill
var response = await agent.RunAsync(
"I need to know the escalation policy for customer tickets. Load the escalation-policy skill and tell me the rules.");
// Assert — the response should reference the load_skill tool invocation
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(
response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any(fc => fc.Name == "load_skill")),
"Expected at least one load_skill FunctionCallContent in the response messages.");
}
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End to end RAG integration tests against a hosted agent backed by Azure AI Search.
/// The hosted agent runs the test container with <c>IT_SCENARIO=azure-search-rag</c>, which
/// wires <see cref="TextSearchProvider"/> over a real <c>SearchClient</c> against the
/// pre-seeded Contoso Outdoors index.
/// </summary>
/// <remarks>
/// Each test asks for a unique <c>*-CANARY-*</c> token that exists ONLY in the seeded
/// document. The model cannot fabricate these tokens from its training data, so a passing
/// assertion is proof the agent retrieved the seeded document via Azure AI Search rather
/// than answering from general knowledge.
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AzureSearchRagHostedAgentTests(AzureSearchRagHostedAgentFixture fixture)
: IClassFixture<AzureSearchRagHostedAgentFixture>
{
private readonly AzureSearchRagHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RagAnswer_CitesSeededReturnPolicy_WhenAskedAboutReturnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: ask about the canary SKU embedded in the seeded Return Policy doc. The
// canary token (TR-CANARY-7821) is unfakeable - it does not exist in any model
// training data, so its presence in the answer is proof the agent retrieved
// the seeded document via the Azure AI Search adapter.
var response = await agent.RunAsync(
"What item code do I get with my return? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("TR-CANARY-7821", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_CitesShippingGuide_WhenAskedAboutShippingAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: canary promo code (SHIP-CANARY-4493) is unique to the seeded Shipping
// Guide doc. Its presence proves the answer was grounded in retrieved content.
var response = await agent.RunAsync(
"What promo code can I use for free overnight shipping? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("SHIP-CANARY-4493", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_StaysGroundedWithoutContext_WhenAskedUnrelatedQuestionAsync()
{
// Arrange: ask something that is NOT covered by the three seeded Contoso documents.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync(
"What is the boiling point of liquid nitrogen in degrees Celsius? " +
"Just give the number with units, no other context.");
// Assert: response is non empty AND does NOT fabricate a Contoso source citation.
// The agent may either answer from its general knowledge or admit uncertainty; either
// is acceptable. The key assertion is that we do not see a fake Contoso link.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.DoesNotContain("contoso.com", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -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=agent-skills</c> mode.
/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
/// and wires them into <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> so the model can
/// discover and load skills via the progressive disclosure pattern.
/// </summary>
public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "agent-skills";
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConformance.IntegrationTests.Support;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=azure-search-rag</c> mode.
/// Wires the container up with an Azure AI Search backed <see cref="Microsoft.Agents.AI.TextSearchProvider"/>
/// adapter that retrieves Contoso Outdoors documents from a pre-provisioned search index before each
/// model invocation.
/// </summary>
/// <remarks>
/// Prerequisites managed out of band:
/// <list type="bullet">
/// <item><description>The <c>it-azure-search-rag</c> agent's managed identity must hold
/// <c>Search Index Data Reader</c> on the search service scope. Granted manually after
/// the first <c>scripts/it-bootstrap-agents.ps1</c> run; see the IT README.</description></item>
/// <item><description>The search index referenced by <c>AZURE_SEARCH_INDEX_NAME</c> must
/// already exist with the documented schema and Contoso Outdoors content. The search
/// service is shared with <c>python-sample-validation.yml</c>; no .NET-side provisioning
/// script ships with this repository.</description></item>
/// </list>
/// </remarks>
public sealed class AzureSearchRagHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "azure-search-rag";
/// <summary>
/// Inject the AZURE_SEARCH_* env vars onto the hosted agent definition so the test container
/// scenario branch can construct its <c>SearchClient</c>. These names are NOT in the platform
/// reserved <c>FOUNDRY_*</c> / <c>AGENT_*</c> namespace so they are safe to set.
/// </summary>
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment[TestSettings.AzureSearchEndpoint] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchEndpoint);
environment[TestSettings.AzureSearchIndexName] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
}
}
@@ -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,300 @@
// 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 ModelDeploymentEnvironmentVariable = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
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>
/// Container responses protocol version declared in <c>container_protocol_versions</c>. Defaults to
/// <c>2.0.0</c> (the only version this image supports). The unsupported-protocol scenario overrides
/// it to <c>1.0.0</c> to assert the container fails fast with a clear, actionable error.
/// </summary>
protected virtual string ResponsesProtocolVersion => "2.0.0";
/// <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>
/// The per-agent <see cref="ProjectOpenAIClient"/> bound to this scenario's hosted agent endpoint
/// (<c>/agents/{name}/endpoint/protocols/openai</c>). Stored hosted-agent responses are only
/// readable through this per-agent client; the project-level responses client returns
/// <c>session_not_accessible</c> (403). Use this to fetch a response by id.
/// </summary>
public ProjectOpenAIClient AgentOpenAIClient { 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.AgentOpenAIClient.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.AgentOpenAIClient.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.AgentOpenAIClient.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, this.ResponsesProtocolVersion));
definition.EnvironmentVariables[ScenarioEnvironmentVariable] = this.ScenarioName;
// Forward the test-side model deployment to the container so it targets the same model the
// tests expect. Without this the container falls back to its hard-coded default (gpt-4o),
// which fails on projects that only deploy a different model.
var modelDeployment = TestConfiguration.GetValue(TestSettings.AzureAIModelDeploymentName);
if (!string.IsNullOrWhiteSpace(modelDeployment))
{
definition.EnvironmentVariables[ModelDeploymentEnvironmentVariable] = modelDeployment;
}
// 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);
this.AgentOpenAIClient = openAIClient;
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,15 @@
// 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=store-config</c> mode.
/// Used by <c>HostedResponsesStoreConfigTests</c> to exercise store/session semantics: <c>store=true</c>
/// vs <c>store=false</c>, <c>previous_response_id</c> and <c>conversation_id</c> forks, and multi-turn
/// recall. The container agent is a neutral assistant with no marker instruction so it never
/// contaminates the content assertions.
/// </summary>
public sealed class HostedResponsesStoreConfigFixture : HostedAgentFixture
{
protected override string ScenarioName => "store-config";
}
@@ -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,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=memory</c> mode.
/// Used by tests that exercise <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
/// running inside the Foundry hosted agent. The memory store name is randomised per fixture
/// instance so concurrent test runs do not share state.
/// </summary>
public sealed class MemoryHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "memory";
/// <summary>
/// Memory store name passed to the test container via <c>IT_MEMORY_STORE_ID</c> so that each
/// fixture instance gets a fresh, isolated bucket of memories.
/// </summary>
public string MemoryStoreId { get; } = $"it-memory-{Guid.NewGuid():N}";
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment["IT_MEMORY_STORE_ID"] = this.MemoryStoreId;
}
}
@@ -0,0 +1,17 @@
// 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=session-files</c> mode.
/// The container exposes three local function tools (<c>GetHomeDirectory</c>, <c>ListFiles</c>,
/// <c>ReadFile</c>) that read from the per-session <c>$HOME</c> sandbox volume — mirroring the
/// <c>Hosted-Files</c> sample. Tests use the alpha
/// <see cref="Azure.AI.Projects.Agents.AgentSessionFiles"/> API to upload a file into the session
/// sandbox, then invoke the agent (pinned to the same <c>agent_session_id</c>) and assert that the
/// agent's tools observed the uploaded file.
/// </summary>
public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "session-files";
}
@@ -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,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox-oauth-consent</c>
/// mode. The container pre-registers a Foundry toolbox (named by <c>IT_TOOLBOX_NAME</c>) whose tool
/// source is fronted by a per-user OAuth connection. The first request that needs the tool must
/// surface an <c>oauth_consent_request</c> instead of running it.
/// </summary>
/// <remarks>
/// Prerequisite (out of band, per project): a Foundry toolbox named by <see cref="ToolboxName"/> must
/// exist in the target project and reference a tool source that returns <c>CONSENT_REQUIRED</c> for an
/// unconsented user (for example a delegated GitHub or Microsoft Graph connection). Override the
/// toolbox name with the <c>IT_TOOLBOX_NAME</c> environment variable. See the project README.
/// </remarks>
public sealed class ToolboxOAuthConsentHostedAgentFixture : HostedAgentFixture
{
private const string ToolboxNameEnvironmentVariable = "IT_TOOLBOX_NAME";
private const string DefaultToolboxName = "auth-paths-oauth-toolbox";
protected override string ScenarioName => "toolbox-oauth-consent";
/// <summary>
/// The Foundry toolbox the container pre-registers. Resolved from <c>IT_TOOLBOX_NAME</c>, falling
/// back to a default that exists in the reference project.
/// </summary>
public string ToolboxName { get; } =
Environment.GetEnvironmentVariable(ToolboxNameEnvironmentVariable) ?? DefaultToolboxName;
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
// Pass the toolbox name into the container so Program.cs wires AddFoundryToolboxes(credential, name).
// IT_TOOLBOX_NAME is a non-reserved key (FOUNDRY_*/AGENT_* are forbidden by the platform).
environment[ToolboxNameEnvironmentVariable] = this.ToolboxName;
}
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a dedicated <c>it-unsupported-protocol</c> agent under the legacy responses protocol
/// <c>1.0.0</c> on purpose. The test container targets protocol <c>2.0.0</c> only, so a request served
/// as <c>1.0.0</c> (no <c>x-agent-foundry-call-id</c> header) must fail fast with a clear <c>501</c>
/// rather than an opaque 500. A dedicated agent name keeps this 1.0.0 deployment isolated from the
/// 2.0.0 scenario agents (notably <c>it-happy-path</c>), whose <c>@latest</c> must stay 2.0.0.
/// See <see cref="UnsupportedProtocolHostedAgentTests"/>.
/// </summary>
public sealed class UnsupportedProtocolHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "unsupported-protocol";
protected override string ResponsesProtocolVersion => "1.0.0";
}
@@ -0,0 +1,36 @@
<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.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<!-- Linked from the Hosted-Files sample so the demo testdata file has a single source of truth. -->
<Content Include="..\..\samples\04-hosting\FoundryHostedAgents\responses\Hosted-Files\resources\contoso_q1_2026_report.txt"
Link="TestData\contoso_q1_2026_report.txt"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Basic round trip, streaming, and container-instruction behaviour for a hosted Responses agent.
/// Store and session semantics live in <see cref="HostedResponsesStoreConfigTests"/>.
/// </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 Instructions_FromContainerDefinition_AreObeyedAsync()
{
// Arrange: the container-side happy-path instructions require every reply to end with the
// marker token CONTAINER-OK. 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("CONTAINER-OK", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,206 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
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>
/// Store and session semantics for a hosted Responses agent: <c>store=true</c> vs <c>store=false</c>,
/// <c>previous_response_id</c> and <c>conversation_id</c> forks, and multi-turn recall. Stored
/// hosted-agent responses are read through the per-agent endpoint client (the project-level client
/// returns 403 <c>session_not_accessible</c>).
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class HostedResponsesStoreConfigTests(HostedResponsesStoreConfigFixture fixture) : IClassFixture<HostedResponsesStoreConfigFixture>
{
private readonly HostedResponsesStoreConfigFixture _fixture = fixture;
[Fact]
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));
// Stored hosted-agent responses are readable only through the per-agent endpoint client.
var fetched = await this._fixture.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(response.ResponseId!);
Assert.NotNull(fetched.Value);
}
[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. Reads go through
// the per-agent endpoint client (the project-level client returns 403 session_not_accessible).
await Assert.ThrowsAnyAsync<Exception>(() =>
this._fixture.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(responseId));
}
[Fact]
public async Task StoredFalse_WithPreviousResponseId_ReadsForkHistoryAndDoesNotAppendAsync()
{
// Contract: a store=true head establishes a retrievable fork that carries history. store=false
// continuations chained to it via previous_response_id read that history transparently but
// never persist their own response, so any number of store=false turns can reuse the same fork
// without appending to it.
var agent = this._fixture.Agent;
var perAgent = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
// Head turn: store=true (default). Establishes the fork with a fact and is retrievable.
var head = await agent.RunAsync("Remember the number 73. Acknowledge briefly.");
var headId = head.ResponseId;
Assert.False(string.IsNullOrWhiteSpace(headId));
var fetchedHead = await perAgent.GetResponseAsync(headId!);
Assert.NotNull(fetchedHead.Value);
ChatClientAgentRunOptions ContinuationOnFork() => new(new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
StoredOutputEnabled = false,
PreviousResponseId = headId,
},
});
// First store=false continuation: reads the fork history (recalls 73) but is not persisted.
var c1 = await agent.RunAsync("What number did I just tell you?", options: ContinuationOnFork());
Assert.Contains("73", c1.Text);
Assert.NotEqual(headId, c1.ResponseId);
await Assert.ThrowsAnyAsync<Exception>(() => perAgent.GetResponseAsync(c1.ResponseId!));
// Second store=false continuation on the SAME fork: still recalls, still does not append.
var c2 = await agent.RunAsync("State that number one more time.", options: ContinuationOnFork());
Assert.Contains("73", c2.Text);
await Assert.ThrowsAnyAsync<Exception>(() => perAgent.GetResponseAsync(c2.ResponseId!));
// The fork head is unchanged and still retrievable after the store=false continuations.
var fetchedHeadAgain = await perAgent.GetResponseAsync(headId!);
Assert.NotNull(fetchedHeadAgain.Value);
}
[Fact]
public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync()
{
// Conversation-id analog of the previous_response_id fork contract: a store=true head populates
// the conversation with history; store=false continuations bound to the same conversation read
// that history transparently but never append to it, so the conversation can be reused by any
// number of store=false turns without growing.
var agent = this._fixture.Agent;
var conversationId = await this._fixture.CreateConversationAsync();
try
{
var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
ChatClientAgentRunOptions ContinuationOnConversation() => new(new ChatOptions
{
ConversationId = conversationId,
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false },
});
// Head turn: store=true, populates the conversation with a fact.
await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored);
var afterHeadCount = await this._fixture.CountConversationItemsAsync(conversationId);
Assert.True(afterHeadCount > 0);
// First store=false continuation: reads the conversation history (recalls 99) but does not append.
var c1 = await agent.RunAsync("What number did I just tell you?", options: ContinuationOnConversation());
Assert.Contains("99", c1.Text);
Assert.Equal(afterHeadCount, await this._fixture.CountConversationItemsAsync(conversationId));
// Second store=false continuation on the SAME conversation: still recalls, still does not append.
var c2 = await agent.RunAsync("State that number one more time.", options: ContinuationOnConversation());
Assert.Contains("99", c2.Text);
Assert.Equal(afterHeadCount, await this._fixture.CountConversationItemsAsync(conversationId));
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
[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]
public async Task MultiTurn_WithPreviousResponseId_RecoversAcrossThreeTurnsAsync()
{
// Arrange: recover the conversation across three turns purely via the previous_response_id
// chain (store=true). Each turn must land on the same hosted MAF session so earlier facts hold.
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var t1 = await agent.RunAsync("Remember two facts: my dog is named Rex and I live in Lisbon. Acknowledge briefly.", session);
Assert.False(string.IsNullOrWhiteSpace(t1.Text));
var t2 = await agent.RunAsync("What is my dog's name?", session);
var t3 = await agent.RunAsync("Which city do I live in?", session);
// Assert: both facts survive across the chain.
Assert.Contains("Rex", t2.Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Lisbon", t3.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
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);
}
}
}
@@ -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,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Validates the Hosted-MemoryAgent end-to-end against a deployed test container running the
/// <c>IT_SCENARIO=memory</c> scenario. Asserts that <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
/// scoped via <see cref="Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext"/> recalls user
/// preferences across multiple turns of a conversation.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class MemoryHostedAgentTests(MemoryHostedAgentFixture fixture) : IClassFixture<MemoryHostedAgentFixture>
{
private readonly MemoryHostedAgentFixture _fixture = fixture;
[Fact]
public async Task Memory_RecallsAcrossTurnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act: teach the agent two pieces of information about the user.
var first = await agent.RunAsync("My name is Taylor and I am planning a hiking trip to Patagonia in November.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("I am travelling with my sister and we love finding scenic viewpoints.", session);
Assert.False(string.IsNullOrWhiteSpace(second.Text));
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side ingestion
// typically completes within ~3 seconds; allow a small margin.
await Task.Delay(TimeSpan.FromSeconds(5));
var recall = await agent.RunAsync("What do you already know about my upcoming trip?", session);
// Assert
Assert.Contains("Patagonia", recall.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Foundry Memory write propagation is eventually consistent and the in-container WhenUpdatesCompletedAsync flush hook is not callable from the test process; this scenario is exercised manually via the sample's smoke.ps1.")]
public async Task Memory_PersistsAcrossSessionsForSameUserAsync()
{
// Arrange: drive a session that establishes some user-private memory. Foundry Memory
// extracts memories more reliably from multi-turn conversations than from a single
// imperative utterance, so mirror the sample's two-turn teaching pattern.
var agent = this._fixture.Agent;
var teachingSession = await agent.CreateSessionAsync();
await agent.RunAsync("My preferred airline is Iberia and I always fly business class.", teachingSession);
await agent.RunAsync("I also prefer aisle seats whenever they are available.", teachingSession);
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side
// ingestion typically completes within ~3 seconds; poll a fresh-session recall a few
// times before failing so the test does not flake on cold caches.
AgentResponse recall = null!;
const int MaxAttempts = 6;
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(5));
var freshSession = await agent.CreateSessionAsync();
recall = await agent.RunAsync("Which airline do I prefer? Reply with just the airline name.", freshSession);
if (recall.Text.Contains("Iberia", StringComparison.OrdinalIgnoreCase))
{
break;
}
}
// Assert
Assert.Contains("Iberia", recall.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,224 @@
# 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. |
| `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. |
| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. |
## 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
eleven 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.
### Per-scenario data-plane RBAC (manual, one time per agent)
The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what
every hosted agent needs to receive inbound inference traffic. Scenarios that read from
external data services need an additional grant on that service to the agent's managed
identity. Today only the `azure-search-rag` scenario falls into this category.
For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader`
on the Azure AI Search service to the agent's managed identity:
```powershell
# 1. Get the agent MI principal id
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
$agent = Invoke-RestMethod `
-Headers @{Authorization="Bearer $tok"; "Foundry-Features"="HostedAgents=V1Preview"} `
-Uri "<project-endpoint>/agents/it-azure-search-rag?api-version=v1"
$mi = $agent.versions.latest.instance_identity.principal_id
# 2. Grant Search Index Data Reader on the search service
az role assignment create `
--assignee-object-id $mi `
--assignee-principal-type ServicePrincipal `
--role "Search Index Data Reader" `
--scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Search/searchServices/<search-service>"
```
Wait ~3 minutes after the grant for RBAC propagation before running the tests.
If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra
auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first:
```powershell
az search service update -g <rg> -n <search-service> --auth-options aadOrApiKey --aad-auth-failure-mode http403
```
### Azure AI Search index prerequisite (one time, out of band)
The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already
exists with the schema and Contoso Outdoors content the test asserts against. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for
the schema and copy-pasteable provisioning snippet. Provisioning the index from your user
identity needs `Search Index Data Contributor` on the search service scope. The search service
itself is treated as pre-existing infrastructure shared with `python-sample-validation.yml`;
no automated provisioning script ships in this repository.
### Required user/SP roles for delegating data-plane grants
To self-serve the `Search Index Data Reader` grant above, you need `User Access Administrator`
(or `Owner`) on the search service scope. To create/seed the index from your own identity, you
need `Search Index Data Contributor`. These are typically granted once per onboarded engineer
and reused for every new IT scenario that needs Search.
### OAuth consent toolbox prerequisite (one time, out of band)
The `toolbox-oauth-consent` scenario assumes a Foundry **toolbox** named by `IT_TOOLBOX_NAME`
(default `auth-paths-oauth-toolbox`) already exists in the target project and references a tool
source fronted by a **per-user OAuth connection** that returns `CONSENT_REQUIRED` for an
unconsented caller (for example a delegated GitHub or Microsoft Graph connection). The test does
not consent on the caller's behalf; it asserts only that the first invocation surfaces an
`oauth_consent_request` consent link to the consumer and that the container stays routable. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md`
(auth path #4) for how the toolbox/connection is set up. No automated provisioning script ships
for the toolbox; it is treated as pre-existing project configuration.
## 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:** some scenarios are validated and active (for example `happy-path`,
> `store-config`, `tool-calling`, `azure-search-rag`, `session-files`); the remaining
> scenarios stay tagged `[Fact(Skip = ...)]` until they have been exercised end to end
> against a live Foundry deployment. Once a scenario has been exercised and its 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) |
| `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) |
| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) |
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 Azure AI Search index referenced by `secrets.AZURE_SEARCH_ENDPOINT` and
`secrets.AZURE_SEARCH_INDEX_NAME` is provisioned out of band (shared with
`python-sample-validation.yml`); CI does not need write access to the search service.
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, and container-instruction behaviour. |
| `HostedResponsesStoreConfigFixture` | `store-config` | `it-store-config` | Store/session semantics: `store=true` vs `store=false`, `previous_response_id` and `conversation_id` forks (read history without appending), multi-turn recall. |
| `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. |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
| `ToolboxOAuthConsentHostedAgentFixture` | `toolbox-oauth-consent` | `it-toolbox-oauth-consent` | Per-user OAuth toolbox consent: pre-registers a consent-gated Foundry toolbox (`IT_TOOLBOX_NAME`), invokes the agent, asserts the consumer captures an `oauth_consent_request` consent link (and the container stays routable, no 424). Requires a consent-gated toolbox in the project (see prerequisite below). |
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
| `MemoryHostedAgentFixture` | `memory` | `it-memory` | `FoundryMemoryProvider` (scoped via `HostedSessionContext`) running inside the hosted agent recalls user preferences across multiple turns; the memory store name is randomised per fixture (`IT_MEMORY_STORE_ID`). |
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
The scenarios marked (placeholder) are already wired into the test container `Program.cs`,
but their assertions stay skipped pending live validation and stabilization of the relevant
`Microsoft.Agents.AI.Foundry.Hosting` API surfaces.
@@ -0,0 +1,241 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable AAIP001 // AgentSessionFiles is experimental
#pragma warning disable OPENAI001 // CreateResponseOptions is experimental
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
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 Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
/// via the alpha <see cref="AgentSessionFiles"/> SDK is read by the deployed hosted agent's
/// container-side <c>ReadFile</c> tool and surfaces in <see cref="AIAgent.RunAsync(string, AgentSession, AgentRunOptions, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// <para>
/// Routing both invocations to the same per-session container requires two clients on the same
/// agent-scoped <see cref="ProjectOpenAIClient"/>: a <see cref="ProjectConversationsClient"/> to
/// pre-create a conversation bound to the agent endpoint, and a <see cref="ProjectResponsesClient"/>
/// for invocation. The session id resolved by the platform on the first call is captured from the
/// <c>x-agent-session-id</c> response header and used to target the
/// <see cref="AgentSessionFiles"/> upload at the same session's <c>$HOME</c>. The second call
/// carries the same conversation_id so it lands in the same container and the agent's
/// <c>ReadFile</c> tool sees the upload.
/// </para>
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture<SessionFilesHostedAgentFixture>
{
private const string FoundryFeaturesHeader = "Foundry-Features";
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
private const string SessionIdHeader = "x-agent-session-id";
private const string TestDataFileName = "contoso_q1_2026_report.txt";
/// <summary>Token that appears verbatim in the test data file. Proof the agent read what we uploaded.</summary>
private const string ExpectedTokenInFile = "1,482.6";
private readonly SessionFilesHostedAgentFixture _fixture = fixture;
[Fact]
public async Task UploadedFile_IsReadByHostedAgentAsync()
{
// Arrange
string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName);
Assert.True(
File.Exists(localPath),
$"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj.");
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
// Admin client + AgentSessionFiles for upload/list/delete (alpha SDK).
var adminOptions = new AgentAdministrationClientOptions();
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
// Build the per-agent OpenAI client. The conversation is created on this client so it is
// bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`).
// A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply.
var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader);
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName };
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall);
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
var conversations = openAIClient.GetProjectConversationsClient();
var responses = openAIClient.GetProjectResponsesClient();
// Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls
// tagged with this conversation_id route to the same per-session container.
var conversation = await conversations.CreateProjectConversationAsync();
string conversationId = conversation.Value.Id;
try
{
// Step 2 — warm-up call. Provisions the per-session container under the conversation and
// lets us read back the resolved agent_session_id from the response header.
var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName);
var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
var warmup = await agent.RunAsync(
"Reply with the single word 'ready' and nothing else.",
options: convOptions);
Assert.False(string.IsNullOrWhiteSpace(warmup.Text));
string agentSessionId = headerCapture.LastValue
?? throw new InvalidOperationException(
$"Expected '{SessionIdHeader}' response header on warm-up but got none.");
// AgentSessionFiles is scoped to the (agent, session) pair at creation time.
var sessionFiles = adminClient.GetAgentSessionFiles(this._fixture.AgentName, agentSessionId);
try
{
// Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
SessionFileWriteResponse writeResponse = await sessionFiles.UploadAsync(
sessionStoragePath: TestDataFileName,
localPath: localPath);
long expectedBytes = new FileInfo(localPath).Length;
Assert.Equal(expectedBytes, writeResponse.BytesWritten);
bool foundEntry = false;
await foreach (SessionDirectoryEntry entry in sessionFiles.GetAllAsync(
sessionStoragePath: "."))
{
if (entry.Name == TestDataFileName && !entry.IsDirectory && entry.SizeInBytes == expectedBytes)
{
foundEntry = true;
break;
}
}
Assert.True(
foundEntry,
$"Expected session directory listing to contain '{TestDataFileName}' ({expectedBytes} bytes) as a file.");
// Step 4 — invoke the agent again on the SAME conversation. The platform routes back to
// the same agent_session_id container, so the agent's ReadFile tool sees the upload.
// The platform mutates session/conversation revision when AgentSessionFiles uploads land,
// so an immediate /responses follow-up races and 400's with "modified concurrently. Please
// retry." — the response message literally tells us to retry. Bounded retry handles it.
var readOptions = new CreateResponseOptions { AgentConversationId = conversationId };
readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(
$"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary."));
ClientResult<ResponseResult> rawResponse = null!;
const int MaxAttempts = 5;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
rawResponse = await responses.CreateResponseAsync(readOptions);
break;
}
catch (ClientResultException ex) when (
ex.Status == 400 &&
ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) &&
attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(2 * attempt));
}
}
string responseText = rawResponse.Value.GetOutputText() ?? string.Empty;
Assert.Equal(agentSessionId, headerCapture.LastValue);
// Assert: the response contains the deterministic token from the file.
Assert.False(string.IsNullOrWhiteSpace(responseText));
Assert.Contains(ExpectedTokenInFile, responseText);
}
finally
{
// Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry —
// the platform owns its lifecycle (no isolation key in our hands).
try
{
await sessionFiles.DeleteAsync(TestDataFileName);
}
catch
{
// Ignore.
}
}
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
/// <summary>
/// Captures a response header value on every pipeline call. Latest value is read after the
/// response completes. Used to grab the platform's <c>x-agent-session-id</c> stamp.
/// </summary>
private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy
{
private readonly string _headerName = headerName;
private string? _lastValue;
public string? LastValue => Volatile.Read(ref this._lastValue);
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
this.Capture(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
this.Capture(message);
}
private void Capture(PipelineMessage message)
{
if (message.Response is not null &&
message.Response.Headers.TryGetValue(this._headerName, out var value) &&
!string.IsNullOrEmpty(value))
{
Volatile.Write(ref this._lastValue, value);
}
}
}
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)
{
message.Request.Headers.Remove(FoundryFeaturesHeader);
message.Request.Headers.Add(FoundryFeaturesHeader, features);
}
}
}
@@ -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]
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]
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]
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]
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,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End-to-end test for the per-user OAuth toolbox consent flow. The hosted container pre-registers a
/// Foundry toolbox whose tool source needs per-user OAuth consent; invoking the agent must surface an
/// <c>oauth_consent_request</c> (carrying a consent link) to the consumer instead of silently running
/// without the tool, and the container must stay routable (no 424) despite the consent-gated toolbox.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolboxOAuthConsentHostedAgentTests(ToolboxOAuthConsentHostedAgentFixture fixture)
: IClassFixture<ToolboxOAuthConsentHostedAgentFixture>
{
private readonly ToolboxOAuthConsentHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build, a consent-gated toolbox in the IT project, and end to end smoke (step 5).")]
public async Task ToolRequiringConsent_SurfacesOAuthConsentRequestToConsumerAsync()
{
// Arrange: the agent is backed by a pre-registered toolbox whose tool source requires
// per-user OAuth consent (the fixture provisioned it, the container stayed routable).
var agent = this._fixture.Agent;
// Act: ask for something that needs the OAuth-protected tool. The toolbox proxy returns
// CONSENT_REQUIRED for the (unconsented) caller, which the hosted agent surfaces as an
// oauth_consent_request output item and marks the response incomplete.
var response = await agent.RunAsync(
"Use the OAuth-protected tool to act on my behalf. List my pull requests.");
// Assert: the consumer captured an oauth_consent_request carrying a usable https consent link.
// The high-level client exposes the (non-OpenAI) consent item as an AIContent whose
// RawRepresentation serializes to the oauth_consent_request wire shape, mirroring how the
// Hosted-Toolbox-AuthPaths REPL client detects it.
var consentLink = response.Messages
.SelectMany(m => m.Contents)
.Select(c => TryGetConsentLink(c.RawRepresentation))
.FirstOrDefault(link => link is not null);
Assert.False(string.IsNullOrWhiteSpace(consentLink),
"Expected the response to surface an oauth_consent_request with a consent link.");
Assert.StartsWith("https://", consentLink, StringComparison.OrdinalIgnoreCase);
}
private static string? TryGetConsentLink(object? raw)
{
if (raw is null)
{
return null;
}
try
{
BinaryData json = ModelReaderWriter.Write(raw, new ModelReaderWriterOptions("J"));
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("type", out JsonElement typeProp)
&& typeProp.GetString() == "oauth_consent_request"
&& root.TryGetProperty("consent_link", out JsonElement linkProp)
&& linkProp.GetString() is string link
&& !string.IsNullOrWhiteSpace(link))
{
return link;
}
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or NotSupportedException or FormatException)
{
// Not a persistable model, or no consent link present — treat as no consent.
}
return null;
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Provokes the protocol-compatibility failure on purpose: the test container (which targets responses
/// protocol <c>2.0.0</c> only) is deployed under responses protocol <c>1.0.0</c>. Invoking it must
/// surface the clear <c>501</c> error the container emits (see <c>HostedProtocolCompatibility</c>),
/// rather than an opaque 500.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class UnsupportedProtocolHostedAgentTests(UnsupportedProtocolHostedAgentFixture fixture) : IClassFixture<UnsupportedProtocolHostedAgentFixture>
{
private readonly UnsupportedProtocolHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RunAsync_OnProtocol1_0_0_FailsWith501UnsupportedProtocolAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: a 1.0.0 deployment sends no x-agent-foundry-call-id header, so the container detects the
// unsupported protocol and fails fast. The OpenAI responses client surfaces the container's HTTP
// error as a ClientResultException.
var ex = await Assert.ThrowsAnyAsync<Exception>(() => agent.RunAsync("Reply with a short greeting."));
// Assert: the failure is the deliberate 501, not an opaque 500, and the body names the required
// protocol so the operator knows the fix.
var clientError = FindClientResultException(ex);
Assert.NotNull(clientError);
Assert.Equal(501, clientError!.Status);
Assert.Contains("2.0.0", clientError.Message, StringComparison.OrdinalIgnoreCase);
}
private static ClientResultException? FindClientResultException(Exception? ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is ClientResultException clientResultException)
{
return clientResultException;
}
}
return null;
}
}
@@ -0,0 +1,175 @@
#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 Foundry 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 Foundry 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.
.NOTES
Per-scenario data-plane RBAC (e.g. `Search Index Data Reader` on the Azure AI Search service
for the `azure-search-rag` scenario) is intentionally NOT performed by this script. Search,
Cosmos, and other backing services are treated as pre-existing infrastructure. Grant the
scenario-specific data role to the agent's managed identity manually after the first run
(see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md).
.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',
'store-config',
'tool-calling',
'tool-calling-approval',
'mcp-toolbox',
'toolbox-oauth-consent',
'custom-storage',
'memory',
'azure-search-rag',
'session-files',
'agent-skills',
'unsupported-protocol'
)
# 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 = '2.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 Foundry User on the project scope to the agent MI (idempotent).
$existing = az role assignment list --assignee $principalId --scope $projectScope `
--query "[?roleDefinitionName=='Foundry User']" 2>$null | ConvertFrom-Json
if ($existing) {
Write-Host " role already assigned"
} else {
Write-Host " granting Foundry 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 'Foundry 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,159 @@
#!/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
}
# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish
# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their
# transitive deps) by reading the prebuilt DLLs at src/<lib>/bin/Release/net10.0/*.dll.
# This:
# 1) Structurally avoids the MSB3026 "file is being used by another process" race that
# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced
# while VBCSCompiler from that build still holds file handles.
# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs.
# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release`
# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the
# preceding "Build Foundry hosted IT (and its deps)" step.
$prebuildProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll",
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll"
)
$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) })
if ($missingPrebuilds.Count -gt 0) {
$msg = @(
"Required prebuilt outputs not found:"
($missingPrebuilds | ForEach-Object { " - $_" })
""
"Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the"
"test project first so its ProjectReference closure populates src/<lib>/bin/Release/net10.0/:"
" dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release"
) -join "`n"
throw $msg
}
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -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
}