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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=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";
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=custom-storage</c> mode.
|
||||
/// The container substitutes the default Responses storage provider with a custom in memory
|
||||
/// implementation so tests can verify that conversation history is read from and written to
|
||||
/// the custom store rather than the platform default.
|
||||
/// </summary>
|
||||
public sealed class CustomStorageHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "custom-storage";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=happy-path</c> mode.
|
||||
/// Used by tests that exercise the basic Responses protocol round trip, multi turn behavior
|
||||
/// (via <c>previous_response_id</c> and <c>conversation_id</c>), and the <c>stored=false</c> flag.
|
||||
/// </summary>
|
||||
public sealed class HappyPathHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "happy-path";
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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";
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=mcp-toolbox</c> mode.
|
||||
/// The container connects to a public MCP server (the Microsoft Learn MCP endpoint) so tests
|
||||
/// can verify MCP tool discovery and invocation flowing through the Foundry hosted agent.
|
||||
/// </summary>
|
||||
public sealed class McpToolboxHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "mcp-toolbox";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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";
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling-approval</c> mode.
|
||||
/// The container declares an AIFunction tagged <c>RequiresApproval=true</c> so tests can exercise
|
||||
/// the human in the loop approval flow (request, grant, deny).
|
||||
/// </summary>
|
||||
public sealed class ToolCallingApprovalHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "tool-calling-approval";
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling</c> mode.
|
||||
/// The container declares one or more deterministic AIFunctions on the server side
|
||||
/// (e.g. <c>GetUtcNow</c>, <c>Multiply(int,int)</c>) so tests can verify tool invocation behavior
|
||||
/// without requiring approvals.
|
||||
/// </summary>
|
||||
public sealed class ToolCallingHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "tool-calling";
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user