mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add Foundry.Hosting.IntegrationTests (#5598)
* Foundry.Hosting.IntegrationTests: scaffold project, fixtures, and 24 tests
Add a new integration test project for Foundry hosted agents alongside the existing Foundry.IntegrationTests project. The project provisions a real Foundry hosted agent per scenario via AgentAdministrationClient.CreateAgentVersionAsync, points it at a single test container image (built and pushed out of band by scripts/it-build-image.ps1 in a follow up commit), and exercises the agent through AIProjectClient.AsAIAgent.
Six scenario fixtures are introduced, each pointing at the same image but selecting behavior via the IT_SCENARIO environment variable on the HostedAgentDefinition:
- HappyPathHostedAgentFixture (round trip, multi turn, stored=false flag)
- ToolCallingHostedAgentFixture (server side AIFunctions)
- ToolCallingApprovalHostedAgentFixture (approval flow)
- ToolboxHostedAgentFixture (Foundry toolbox)
- McpToolboxHostedAgentFixture (MCP backed toolbox)
- CustomStorageHostedAgentFixture (custom storage provider)
24 tests across 6 test classes are scaffolded. All are tagged Skip pending the test container build and the end to end smoke iteration in follow up commits. Once the container is in place the Skip annotations can be removed scenario by scenario.
Adds an IT_HOSTED_AGENT_IMAGE constant to the shared TestSettings so every IT project agrees on the env var name the build script emits.
* Foundry.Hosting.IntegrationTests: add TestContainer, build script, slnx, README
Adds the rest of the integration test infrastructure on top of the previous scaffolding commit:
* Foundry.Hosting.IntegrationTests.TestContainer csproj and Program.cs implementing the multi scenario container (one image, IT_SCENARIO env var dispatches between happy-path, tool-calling, tool-calling-approval, toolbox, mcp-toolbox, and custom-storage). The toolbox, mcp-toolbox, and custom-storage branches are placeholders pending API surface stabilization.
* Dockerfile and dockerignore in the test container project, using the contributor pattern matching the investigation work (host side dotnet publish, container only does COPY out/).
* scripts/it-build-image.ps1 with mandatory Registry parameter (no hardcoded ACR), content hashed tags so unchanged source results in a no op push, and emits IT_HOSTED_AGENT_IMAGE for shells and CI to consume.
* slnx entry for both new projects.
* README in the IT project covering env vars, image build, scenario table, and current placeholder status.
Steps still pending: end to end smoke (step 5) and CI workflow integration (step 6) require a live Foundry deployment and ACR push, so they land in follow up commits.
* Foundry.Hosting.IntegrationTests: address PR 5598 review feedback
Fix issues raised by Copilot review:
* it-build-image.ps1: hash file contents, not the path list, so any source edit produces a fresh tag. Normalize Registry input by stripping scheme and trailing slash before deriving the ACR short name. Validate the short name is non empty.
* HostedAgentFixture: route GetAgentAsync through _adminClient (which has the FoundryFeaturesPolicy attached) instead of through _projectClient.AgentAdministrationClient (which does not).
* HostedAgentFixture FoundryFeaturesPolicy: replace Headers.Add with Remove plus Add so retries cannot accumulate duplicate headers.
* HappyPath, ToolCalling, ToolCallingApproval, CustomStorage tests: create the AgentSession before turn 1 and reuse it for both turns. The previous pattern created the session after turn 1 so turn 2 had no link to turn 1, defeating the multi turn assertion.
* .NET: Foundry.Hosting.IntegrationTests: constrain to net10.0 + dotnet format autofix
- Set <TargetFrameworks>net10.0</TargetFrameworks>: the project references both
Microsoft.Agents.AI.Foundry.Hosting (net8/9/10 only) and AgentConformance.IntegrationTests
(net10.0;net472 — inherits the tests-default TFM list). The intersection is net10.0;
the previous $(TargetFrameworksCore) triple caused NU1702 + System.Text.Json version
conflicts on the net8.0/net9.0 builds because AgentConformance had no matching asset.
- Apply `dotnet format` autofix on the test files (IDE0005, IDE0009, IDE0032, IMPORTS).
* .NET: Foundry.Hosting.IntegrationTests.TestContainer/Program.cs: add UTF-8 BOM
CI's check-format requires charset=utf-8-bom per .editorconfig.
* Foundry.Hosting IntegrationTests: wire end-to-end CI flow against hosted agents
Make the integration tests usable end-to-end against a live Foundry deployment, including
a per-run rebuild of the test container so framework code changes are exercised.
Fixture (HostedAgentFixture.cs)
* Switch from per-run unique agent names to stable scenario-keyed names (it-happy-path,
it-tool-calling, ...). The agent's managed identity carries the Azure AI User role on
the project scope, which is required for inbound inference; deleting the agent recycles
the MI and breaks that role assignment, so we keep the agent across runs and only churn
versions.
* Add IT_RUN_ID env var to defeat Foundry's content-addressed version dedup; otherwise a
rerun just receives the existing version and Dispose deletes it.
* PATCH the per-agent endpoint with AgentEndpointConfig (Responses protocol, version
selector at 100% to the new version). Without this, /agents/{name}/endpoint/protocols/
openai/responses returns HTTP 400.
* Build a per-agent ProjectOpenAIClient (not the cached projectClient.ProjectOpenAIClient,
which is bound to the project-level URL); set AgentName in options so the URL routes
through the agent endpoint, and add the Foundry-Features header to the inference
pipeline.
* Use Versions (which serializes to container_protocol_versions) instead of the
deprecated ProtocolVersions; the server now rejects the legacy field.
* On Dispose, delete only the version this fixture created. Never delete the agent.
Tests
* Tag every HostedAgentTests class with [Trait("Category", "FoundryHostedAgents")] so the
CI workflow can route them to a separate Foundry project than the rest of the
integration suite.
CI workflow (.github/workflows/dotnet-build-and-test.yml)
* Add a foundryHosting paths-filter covering Microsoft.Agents.AI.Foundry.Hosting and its
in-repo dependency chain (Foundry, Agents.AI, Agents.AI.Abstractions), the test
container, the test fixture, Directory.Packages.props, the build script, and this
workflow file. Skip the costly hosted-agent steps when none of those changed.
* Add "Build and push Foundry Hosted Agents test container" step that invokes
scripts/it-build-image.ps1 against vars.IT_HOSTED_AGENT_REGISTRY and pipes the resulting
IT_HOSTED_AGENT_IMAGE=<tag> into GITHUB_ENV.
* Add "Run Foundry Hosted Agents Integration Tests" step that filters in only the new
trait, with AZURE_AI_PROJECT_ENDPOINT/AZURE_AI_MODEL_DEPLOYMENT_NAME pointed at
IT_HOSTED_AGENT_PROJECT_ENDPOINT/IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME (Tao project,
East US 2; the SK IT project's region does not yet support hosted agents preview).
* Exclude the new trait from the existing "Run Integration Tests" step.
* TEMP: drop the != 'pull_request' guard on the new steps and on Azure CLI Login when the
paths-filter triggers, so PR #5598 can validate the wiring before promoting to merge
queue only. Restore the original guard after one green PR run.
Build script (scripts/it-build-image.ps1)
* Hash now spans TestContainer source AND its referenced framework projects so any
framework code change forces a fresh tag and a real docker push; the previous
TestContainer-only hash silently reused stale images on framework edits.
Bootstrap script (dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1)
* New idempotent script that creates the six stable scenario agents and grants Azure AI
User on the project scope to each agent's MI. Run once per Foundry project. Includes
AAD-graph propagation retries because newly created MIs take time to appear there.
README (dotnet/tests/Foundry.Hosting.IntegrationTests/README.md)
* Document the bootstrap prerequisite, the regional caveat (East US 2 is the only region
we have validated; East US returned "Unsupported region" at the time of writing), the
per-run image rebuild, and the CI wiring including the SP RBAC requirements.
SDK pin (TEMP)
* Bump Microsoft.Agents.AI.Foundry.Hosting's Azure.AI.Projects VersionOverride to
2.1.0-alpha.20260505.1 from the azure-sdk public daily feed (added to nuget.config).
This release is the first that builds the per-agent inference URL as
/agents/{name}/endpoint/protocols/openai (the 2.1.0-beta.1 release builds
.../openai/openai/v1, which the server rejects). Revert both the feed and the override
once the URL fix lands in a stable Azure.AI.Projects release.
* Foundry.Hosting IntegrationTests: revert alpha SDK pin; move endpoint PATCH to bootstrap
The alpha SDK pin (Azure.AI.Projects 2.1.0-alpha.20260505.1 from the azure-sdk public
daily feed) was needed only for the URL routing fix and the strongly-typed
AgentEndpointConfig/PatchAgentOptions wrapper. We do not need either right now: the
fixture stays compatible with the public 2.1.0-beta.1 by moving the one-time endpoint
PATCH to the bootstrap script (it sets version_selector to FixedRatio @latest, so each
new fixture run becomes the served version automatically without a per-run PATCH from
the test code). The hosted-agent invocation path will start working end-to-end once the
URL routing fix lands in a stable Azure.AI.Projects release; until then the tests stay
[Fact(Skip = ...)] as documented.
* Revert dotnet/nuget.config: drop the azure-sdk-for-net public feed.
* Revert Microsoft.Agents.AI.Foundry.Hosting.csproj VersionOverride to 2.1.0-beta.1.
* Revert Microsoft.Agents.AI.Foundry.UnitTests and Microsoft.Agents.AI.Foundry.Hosting.UnitTests
Azure.AI.Projects pin (they had been bumped to align Azure.Core 1.54 transitive).
* Drop the AgentEndpointConfig PATCH block from HostedAgentFixture.cs (the type is
alpha-only). Replace with a comment pointing at the bootstrap script.
* Bootstrap script (it-bootstrap-agents.ps1) now also PATCHes each agent's endpoint
with version_selector=@latest if not already set. Idempotent.
* Foundry.Hosting IntegrationTests: drop accidentally committed filtered.slnx
* Foundry.Hosting IntegrationTests: revert TEMP PR override on Azure CLI Login + IT steps
The previous attempt to validate the new hosted-agent IT wiring on PR #5598 failed
because the PR is from a fork (rogerbarreto/agent-framework-public). GitHub never passes
environment secrets to fork PRs regardless of event-name guards on individual steps,
so 'azure/login@v2' fails with 'client-id and tenant-id are not supplied'. Restore the
original github.event_name != 'pull_request' guard. The new steps will execute on
push to main and on merge_group runs.
* Foundry.Hosting IntegrationTests: invoke build-and-push script with absolute path
The pwsh shell on the GitHub Actions runner couldn't resolve ./scripts/it-build-image.ps1
when the step had no working-directory set; the step inherits the runner's PWD which is
not always the repo root after preceding steps. Use github.workspace explicitly to remove
the ambiguity.
* Foundry.Hosting IntegrationTests: move it-build-image.ps1 inside the IT project tree
The previous location at scripts/it-build-image.ps1 lived outside the sparse-checkout
paths the workflow uses (.github, dotnet, python, declarative-agents), so the runner
never had the file when the new step tried to invoke it. Move the script next to its
sibling it-bootstrap-agents.ps1 inside the IT project tree, and anchor its relative
paths to the repo root via so callers can invoke it from any PWD.
* Move scripts/it-build-image.ps1 -> dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1
* Add Push-Location to the resolved repo root inside the script (Pop-Location in finally)
so the existing relative paths (TestContainerProject, hashed src dirs) keep working
no matter where the script is invoked from.
* Update the workflow path filter and the step's invocation path to the new location.
* Foundry.Hosting IntegrationTests: enable 5 HappyPath tests on the live Foundry endpoint
The fixture already constructs ProjectOpenAIClient via the per-agent path that beta.1
supports (new ProjectOpenAIClient(uri, cred, opts { AgentName })), so no SDK pin bump
is required to run the smoke tests end-to-end. Un-skip the 5 tests that pass against
the live test container.
Tests un-skipped (verified passing locally against tao-foundry-prj):
* RunAsync_ReturnsNonEmptyTextAsync
* RunStreamingAsync_YieldsAtLeastOneUpdateAsync
* MultiTurn_WithPreviousResponseId_PreservesContextAsync
* StoredFalse_Baseline_DoesNotPersistResponseAsync
* Instructions_FromContainerDefinition_AreObeyedAsync
Tests still skipped with a more specific reason (4 of 9 in HappyPath plus all
ToolCalling*, McpToolbox, Toolbox, CustomStorage) because the test container does not
yet emit usable response_id / conversation_id chains, and the placeholder scenarios are
not implemented in the test container's Program.cs. These are test container limitations,
not infra bugs, and can be un-skipped as the container surfaces stabilize.
* Foundry.Hosting IntegrationTests: extract hosted IT into parallel job, add Workflows dep
Address Wesley's review feedback on PR #5598:
1. Pull Foundry hosted-agent IT into its own dotnet-foundry-hosted-it job that runs in parallel to dotnet-build and dotnet-test. Same path-filter gate keeps it skipped on unrelated edits. Builds only the filtered solution containing Foundry.Hosting.IntegrationTests and src deps. dotnet-build-and-test-check now waits on it too.
2. Add Microsoft.Agents.AI.Workflows to the foundryHosting paths-filter and to hashedDirs in it-build-image.ps1 since Foundry.Hosting transitively depends on it.
TFM constraint on the IT csproj stays at net10.0 because AgentConformance.IntegrationTests targets net10/net472 and is consumed by ~12 other IT projects on net472.
---------
Co-authored-by: Roger Barreto <rbarreto@microsoft.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
65455751a4
commit
51ad460d5f
@@ -37,6 +37,7 @@ jobs:
|
||||
outputs:
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -47,6 +48,21 @@ jobs:
|
||||
- 'dotnet/**'
|
||||
cosmosdb:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
||||
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
|
||||
# provisions live agents). Only run it when the project under test, its
|
||||
# dependency chain, the test container, the test fixture, or their tooling
|
||||
# changed. Keep this list in sync with $hashedDirs in scripts/it-build-image.ps1.
|
||||
foundryHosting:
|
||||
- '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/**'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
|
||||
- 'dotnet/Directory.Packages.props'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
# run only if 'dotnet' files were changed
|
||||
- name: dotnet tests
|
||||
if: steps.filter.outputs.dotnet == 'true'
|
||||
@@ -259,6 +275,7 @@ jobs:
|
||||
--report-xunit-trx `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-not-trait "Category=IntegrationDisabled" `
|
||||
--filter-not-trait "Category=FoundryHostedAgents" `
|
||||
--parallel-algorithm aggressive `
|
||||
--max-threads 2.0x
|
||||
env:
|
||||
@@ -299,11 +316,101 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||
|
||||
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
|
||||
# live agents on a separate Foundry project). Running it in its own job keeps the overall
|
||||
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
|
||||
# gated on paths-filter.outputs.foundryHostingChanges so unrelated edits skip the work.
|
||||
dotnet-foundry-hosted-it:
|
||||
needs: paths-filter
|
||||
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.foundryHostingChanges == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
targetFramework: net10.0
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Generate test solution (no samples)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/agent-framework-dotnet.slnx `
|
||||
-TargetFramework $env:targetFramework `
|
||||
-Configuration $env:configuration `
|
||||
-ExcludeSamples `
|
||||
-OutputPath dotnet/filtered.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Generate Foundry hosted IT filtered solution
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/filtered.slnx `
|
||||
-TargetFramework $env:targetFramework `
|
||||
-Configuration $env:configuration `
|
||||
-TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
|
||||
-OutputPath dotnet/filtered-foundry-hosted.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Build Foundry hosted IT (and its deps)
|
||||
shell: bash
|
||||
run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# We rebuild and push the test container image on every IT run so framework code changes
|
||||
# are picked up; the image tag is content-hashed across the test container source AND its
|
||||
# framework project references, so identical content is a no-op push.
|
||||
- name: Build and push Foundry Hosted Agents test container
|
||||
id: build-foundry-hosted-image
|
||||
shell: pwsh
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
$registry = "${{ vars.IT_HOSTED_AGENT_REGISTRY }}"
|
||||
if ([string]::IsNullOrWhiteSpace($registry)) {
|
||||
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
|
||||
}
|
||||
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||
|
||||
- name: Run Foundry Hosted Agents Integration Tests
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
run: |
|
||||
dotnet test --solution ./filtered-foundry-hosted.slnx `
|
||||
-f $env:targetFramework `
|
||||
-c $env:configuration `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-trait "Category=FoundryHostedAgents"
|
||||
env:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
|
||||
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
|
||||
|
||||
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
|
||||
dotnet-build-and-test-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dotnet-build, dotnet-test]
|
||||
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
|
||||
steps:
|
||||
- name: Get Date
|
||||
shell: bash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Solution>
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<BuildType Name="Debug" />
|
||||
<BuildType Name="Publish" />
|
||||
@@ -596,6 +596,8 @@
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
|
||||
@@ -21,6 +21,9 @@ internal static class TestSettings
|
||||
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
|
||||
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
|
||||
|
||||
// Foundry Hosted Agents (Foundry.Hosting integration tests)
|
||||
public const string FoundryHostingItImage = "IT_HOSTED_AGENT_IMAGE";
|
||||
|
||||
// Copilot Studio
|
||||
public const string CopilotStudioAgentAppId = "COPILOTSTUDIO_AGENT_APP_ID";
|
||||
public const string CopilotStudioDirectConnectUrl = "COPILOTSTUDIO_DIRECT_CONNECT_URL";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
.git/
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
*.user
|
||||
*.suo
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "foundry-hosting-it-test-container.dll"]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Foundry.Hosting.IntegrationTests.TestContainer</RootNamespace>
|
||||
<AssemblyName>foundry-hosting-it-test-container</AssemblyName>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<UseMicrosoftTestingPlatformRunner>false</UseMicrosoftTestingPlatformRunner>
|
||||
<TestingPlatformDotnetTestSupport>false</TestingPlatformDotnetTestSupport>
|
||||
<NoWarn>$(NoWarn);NU1605;NU1903;AAIP001;OPENAI001</NoWarn>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="xunit.v3.mtp-v2" />
|
||||
<PackageReference Remove="xunit.runner.visualstudio" />
|
||||
<PackageReference Remove="Moq" />
|
||||
<PackageReference Remove="xRetry.v3" />
|
||||
<PackageReference Remove="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
|
||||
<Using Remove="Xunit" />
|
||||
<Using Remove="xRetry.v3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Foundry hosted agent test container for Foundry.Hosting.IntegrationTests.
|
||||
//
|
||||
// One image, many scenarios. The IT_SCENARIO environment variable selects which agent
|
||||
// behavior is wired up at startup. Each scenario corresponds to one test fixture and
|
||||
// one set of tests in the IT project.
|
||||
//
|
||||
// The platform injects FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_AGENT_NAME, FOUNDRY_AGENT_VERSION,
|
||||
// PORT, and APPLICATIONINSIGHTS_CONNECTION_STRING. We never set FOUNDRY_* or AGENT_* names
|
||||
// from the test side because they are reserved by the platform.
|
||||
|
||||
var scenario = Environment.GetEnvironmentVariable("IT_SCENARIO") ?? "happy-path";
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
var projectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential());
|
||||
|
||||
AIAgent agent = scenario switch
|
||||
{
|
||||
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
|
||||
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
|
||||
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
|
||||
"toolbox" => CreateToolboxAgent(projectClient, deployment),
|
||||
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
|
||||
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var port = Environment.GetEnvironmentVariable("PORT");
|
||||
if (!string.IsNullOrEmpty(port))
|
||||
{
|
||||
builder.WebHost.UseUrls($"http://+:{port}");
|
||||
}
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.MapGet("/readiness", () => Results.Ok());
|
||||
app.Run();
|
||||
|
||||
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful AI assistant. Always reply with exactly the single word ECHO unless the user explicitly asks a question that requires a different answer.",
|
||||
name: "happy-path-agent",
|
||||
description: "Round trip and conversation test agent.");
|
||||
|
||||
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful assistant. Use the GetUtcNow and Multiply tools when appropriate.",
|
||||
name: "tool-calling-agent",
|
||||
description: "Server side tool calling test agent.",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetUtcNow),
|
||||
AIFunctionFactory.Create(Multiply)
|
||||
]);
|
||||
|
||||
static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire approval required AIFunction once the public surface is finalized.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful assistant. Use the SendEmail tool when asked to send a message; it requires user approval before running.",
|
||||
name: "tool-calling-approval-agent",
|
||||
description: "Approval flow test agent (placeholder).",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(SendEmail)
|
||||
]);
|
||||
|
||||
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
|
||||
name: "toolbox-agent",
|
||||
description: "Toolbox test agent (placeholder).",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetEnvironmentName)
|
||||
]);
|
||||
|
||||
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are an assistant with access to Microsoft Learn documentation via MCP.",
|
||||
name: "mcp-toolbox-agent",
|
||||
description: "MCP toolbox test agent (placeholder).");
|
||||
|
||||
static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: substitute custom IResponsesStorageProvider in DI.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a helpful assistant.",
|
||||
name: "custom-storage-agent",
|
||||
description: "Custom storage test agent (placeholder).");
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
[Description("Multiplies two integers and returns the product.")]
|
||||
static int Multiply([Description("First operand")] int a, [Description("Second operand")] int b) => a * b;
|
||||
|
||||
[Description("Sends an email. Requires user approval.")]
|
||||
static string SendEmail(
|
||||
[Description("Recipient address")] string to,
|
||||
[Description("Email subject")] string subject) =>
|
||||
$"Email sent to {to} with subject '{subject}'.";
|
||||
|
||||
[Description("Returns the deployment environment name.")]
|
||||
static string GetEnvironmentName() => "integration-test";
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for a hosted agent whose container wires an in memory custom storage provider
|
||||
/// in place of the platform default. Verifies the model still works and that multi turn
|
||||
/// behavior reads from the custom store.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class CustomStorageHostedAgentTests(CustomStorageHostedAgentFixture fixture)
|
||||
: IClassFixture<CustomStorageHostedAgentFixture>
|
||||
{
|
||||
private readonly CustomStorageHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task RoundTrip_WorksWithCustomStorageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with the word 'stored'.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task MultiTurn_PreviousResponseId_ReadsFromCustomStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("My favorite city is Lisbon. Acknowledge briefly.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("What city did I just tell you?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Lisbon", second.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=custom-storage</c> mode.
|
||||
/// The container substitutes the default Responses storage provider with a custom in memory
|
||||
/// implementation so tests can verify that conversation history is read from and written to
|
||||
/// the custom store rather than the platform default.
|
||||
/// </summary>
|
||||
public sealed class CustomStorageHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "custom-storage";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=happy-path</c> mode.
|
||||
/// Used by tests that exercise the basic Responses protocol round trip, multi turn behavior
|
||||
/// (via <c>previous_response_id</c> and <c>conversation_id</c>), and the <c>stored=false</c> flag.
|
||||
/// </summary>
|
||||
public sealed class HappyPathHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "happy-path";
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Base fixture for Foundry Hosted Agent integration tests.
|
||||
///
|
||||
/// Each derived fixture represents one scenario (happy path, tool calling, toolbox, etc.) and
|
||||
/// targets a stable, scenario-keyed agent name (e.g. <c>it-happy-path</c>). The fixture creates
|
||||
/// a new <see cref="ProjectsAgentVersion"/> on each <see cref="InitializeAsync"/>, polls until
|
||||
/// active, patches the agent's endpoint to route 100% of traffic to that new version, then
|
||||
/// exposes the wrapped <see cref="AIAgent"/> for tests via <see cref="Agent"/>.
|
||||
///
|
||||
/// On <see cref="DisposeAsync"/> only the version created by this fixture is removed; the agent
|
||||
/// itself (and therefore its managed identity) is left in place. This is critical because the
|
||||
/// agent's managed identity must hold <c>Azure AI User</c> on the project scope to serve
|
||||
/// inbound inference traffic, and that role assignment is lost when the agent itself is deleted.
|
||||
///
|
||||
/// Prerequisite: each scenario agent (and its managed identity) must exist and have
|
||||
/// <c>Azure AI User</c> pre-granted on the project scope before the tests run. See
|
||||
/// <c>scripts/it-bootstrap-agents.ps1</c>.
|
||||
///
|
||||
/// The container image is the same for every scenario; the scenario itself is selected by
|
||||
/// the <c>IT_SCENARIO</c> environment variable in <see cref="HostedAgentDefinition.EnvironmentVariables"/>,
|
||||
/// configured by each derived fixture via <see cref="ScenarioName"/>.
|
||||
/// </summary>
|
||||
public abstract class HostedAgentFixture : IAsyncLifetime
|
||||
{
|
||||
private const string ScenarioEnvironmentVariable = "IT_SCENARIO";
|
||||
private const string RunIdEnvironmentVariable = "IT_RUN_ID";
|
||||
private const string FoundryFeaturesHeader = "Foundry-Features";
|
||||
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview";
|
||||
private const string EnableVnextExperienceMetadataKey = "enableVnextExperience";
|
||||
|
||||
private AgentAdministrationClient _adminClient = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Scenario keyword passed to the container as <c>IT_SCENARIO</c>. Derived fixtures override.
|
||||
/// </summary>
|
||||
protected abstract string ScenarioName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU request for the hosted agent container. Override per scenario if needed.
|
||||
/// </summary>
|
||||
protected virtual string Cpu => "0.25";
|
||||
|
||||
/// <summary>
|
||||
/// Memory request for the hosted agent container. Override per scenario if needed.
|
||||
/// </summary>
|
||||
protected virtual string Memory => "0.5Gi";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time to wait for <see cref="AgentVersionStatus.Active"/> after creation.
|
||||
/// </summary>
|
||||
protected virtual TimeSpan ProvisioningTimeout => TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// The wrapped agent. Available after <see cref="InitializeAsync"/>.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The stable, scenario keyed agent name registered in Foundry (e.g. <c>it-happy-path</c>).
|
||||
/// The agent itself is provisioned out of band (see <c>scripts/it-bootstrap-agents.ps1</c>);
|
||||
/// each test run only adds and removes a version under it.
|
||||
/// </summary>
|
||||
public string AgentName { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The agent version assigned by Foundry on creation.
|
||||
/// </summary>
|
||||
public string AgentVersion { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying <see cref="AIProjectClient"/>, useful for tests that need to talk
|
||||
/// to the conversations or responses APIs directly (e.g. to assert chain visibility).
|
||||
/// </summary>
|
||||
public AIProjectClient ProjectClient { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a server side conversation that tests can pass via <c>ChatOptions.ConversationId</c>
|
||||
/// to exercise multi turn flows backed by the Foundry conversations service.
|
||||
/// </summary>
|
||||
public async Task<string> CreateConversationAsync()
|
||||
{
|
||||
var response = await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync().ConfigureAwait(false);
|
||||
return response.Value.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a previously created conversation. Used by tests in their cleanup blocks.
|
||||
/// </summary>
|
||||
public async Task DeleteConversationAsync(string conversationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(conversationId).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup mirroring DisposeAsync.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts items currently stored in a conversation. Used by tests verifying that a
|
||||
/// <c>stored=false</c> request did not append to the conversation.
|
||||
/// </summary>
|
||||
public async Task<int> CountConversationItemsAsync(string conversationId)
|
||||
{
|
||||
var count = 0;
|
||||
await foreach (var _ in this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
|
||||
var image = TestConfiguration.GetRequiredValue(TestSettings.FoundryHostingItImage);
|
||||
|
||||
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
|
||||
|
||||
var adminOptions = new AgentAdministrationClientOptions();
|
||||
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
|
||||
this._adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
|
||||
this.ProjectClient = new AIProjectClient(endpoint, credential);
|
||||
|
||||
this.AgentName = $"it-{this.ScenarioName}";
|
||||
|
||||
var definition = new HostedAgentDefinition(cpu: this.Cpu, memory: this.Memory)
|
||||
{
|
||||
Image = image,
|
||||
};
|
||||
definition.Versions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0"));
|
||||
definition.EnvironmentVariables[ScenarioEnvironmentVariable] = this.ScenarioName;
|
||||
// Foundry deduplicates versions by content hash, so a fixture re-using the same
|
||||
// definition would just receive the bootstrap version and then delete it on dispose.
|
||||
// Adding a per-run env var forces a brand new version that the dispose can safely remove
|
||||
// without touching the bootstrap version (which keeps the agent alive across runs).
|
||||
definition.EnvironmentVariables[RunIdEnvironmentVariable] = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Allow derived fixtures to layer additional environment variables before submission.
|
||||
this.ConfigureEnvironment(definition.EnvironmentVariables);
|
||||
|
||||
var creationOptions = new ProjectsAgentVersionCreationOptions(definition);
|
||||
creationOptions.Metadata[EnableVnextExperienceMetadataKey] = "true";
|
||||
|
||||
// Adds a new version under the (stable) agent name. Auto-creates the agent on first run.
|
||||
// The agent is intentionally never deleted because its managed identity must hold the
|
||||
// pre-granted role assignment for inbound inference to succeed (see class docs).
|
||||
var version = await this._adminClient.CreateAgentVersionAsync(this.AgentName, creationOptions).ConfigureAwait(false);
|
||||
var activeVersion = await WaitForActiveAsync(this._adminClient, version.Value, this.ProvisioningTimeout).ConfigureAwait(false);
|
||||
this.AgentVersion = activeVersion.Version;
|
||||
|
||||
// The agent endpoint must already be configured to route via @latest. The bootstrap
|
||||
// script (scripts/it-bootstrap-agents.ps1) does that one-time per agent. Each new
|
||||
// version we create automatically becomes the served one because @latest resolves
|
||||
// to the highest version number.
|
||||
//
|
||||
// Build a per-agent ProjectOpenAIClient (the cached projectClient.ProjectOpenAIClient is bound
|
||||
// to the project-level URL and cannot serve a hosted agent). AgentName on the options selects
|
||||
// the per-agent URL suffix `/agents/{name}/endpoint/protocols/openai`. The Foundry-Features
|
||||
// header is also required on the invocation pipeline (not just the admin one) for hosted agents.
|
||||
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this.AgentName };
|
||||
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
|
||||
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
|
||||
var responsesClient = openAIClient.GetProjectResponsesClient();
|
||||
|
||||
this.Agent = responsesClient.AsIChatClient().AsAIAgent(name: this.AgentName);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (this._adminClient is null || this.AgentName is null || this.AgentVersion is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Delete only the version we created. The agent itself MUST stay so that its
|
||||
// managed identity (and the pre-granted Azure AI User role on it) survive across
|
||||
// test runs. If we delete the agent, Foundry mints a new MI on the next create
|
||||
// and inference fails with PermissionDenied until the role is regranted.
|
||||
await this._adminClient.DeleteAgentVersionAsync(this.AgentName, this.AgentVersion).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup. Never throw from DisposeAsync because that would mask
|
||||
// the real test failure. Orphan versions accumulate harmlessly; a maintenance
|
||||
// script can prune them when needed.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hook for derived fixtures to add scenario specific environment variables.
|
||||
/// Reserved names (anything matching <c>FOUNDRY_*</c> or <c>AGENT_*</c>) are forbidden by the platform.
|
||||
/// </summary>
|
||||
protected virtual void ConfigureEnvironment(IDictionary<string, string> environment)
|
||||
{
|
||||
}
|
||||
|
||||
private static async Task<ProjectsAgentVersion> WaitForActiveAsync(
|
||||
AgentAdministrationClient adminClient,
|
||||
ProjectsAgentVersion version,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (version.Status != AgentVersionStatus.Active && version.Status != AgentVersionStatus.Failed)
|
||||
{
|
||||
if (DateTimeOffset.UtcNow > deadline)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Hosted agent '{version.Name}' version '{version.Version}' did not become Active within {timeout.TotalSeconds:F0}s. Last status: {version.Status}.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None).ConfigureAwait(false);
|
||||
version = (await adminClient.GetAgentVersionAsync(version.Name, version.Version).ConfigureAwait(false)).Value;
|
||||
}
|
||||
|
||||
if (version.Status != AgentVersionStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Hosted agent '{version.Name}' version '{version.Version}' failed to deploy. Status: {version.Status}.");
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that adds the Foundry feature header on every request.
|
||||
/// Required for hosted agent operations until the V1 preview flag is removed.
|
||||
/// </summary>
|
||||
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void SetHeader(PipelineMessage message)
|
||||
{
|
||||
// Set rather than Add to avoid duplicate headers if the pipeline reprocesses
|
||||
// the request (retries) or if multiple policies attempt to set the same key.
|
||||
message.Request.Headers.Remove(FoundryFeaturesHeader);
|
||||
message.Request.Headers.Add(FoundryFeaturesHeader, features);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=mcp-toolbox</c> mode.
|
||||
/// The container connects to a public MCP server (the Microsoft Learn MCP endpoint) so tests
|
||||
/// can verify MCP tool discovery and invocation flowing through the Foundry hosted agent.
|
||||
/// </summary>
|
||||
public sealed class McpToolboxHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "mcp-toolbox";
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling-approval</c> mode.
|
||||
/// The container declares an AIFunction tagged <c>RequiresApproval=true</c> so tests can exercise
|
||||
/// the human in the loop approval flow (request, grant, deny).
|
||||
/// </summary>
|
||||
public sealed class ToolCallingApprovalHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "tool-calling-approval";
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling</c> mode.
|
||||
/// The container declares one or more deterministic AIFunctions on the server side
|
||||
/// (e.g. <c>GetUtcNow</c>, <c>Multiply(int,int)</c>) so tests can verify tool invocation behavior
|
||||
/// without requiring approvals.
|
||||
/// </summary>
|
||||
public sealed class ToolCallingHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "tool-calling";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
|
||||
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
|
||||
/// that the model can invoke those tools and that client side toolbox additions surface alongside
|
||||
/// server side registrations when listed.
|
||||
/// </summary>
|
||||
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "toolbox";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Constrained to net10.0: Microsoft.Agents.AI.Foundry.Hosting targets net8/9/10 only
|
||||
(no net472 — depends on ASP.NET Core), while AgentConformance.IntegrationTests
|
||||
inherits the default tests TFM list (net10.0;net472). The intersection is net10.0.
|
||||
-->
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);CS8793;NU1605;NU1903;AAIP001</NoWarn>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Round trip and conversation oriented integration tests against a hosted Responses agent.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class HappyPathHostedAgentTests(HappyPathHostedAgentFixture fixture) : IClassFixture<HappyPathHostedAgentFixture>
|
||||
{
|
||||
private readonly HappyPathHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNonEmptyTextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with a short greeting.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_YieldsAtLeastOneUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var collected = new System.Collections.Generic.List<string>();
|
||||
await foreach (var update in agent.RunStreamingAsync("Reply with a short greeting."))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
collected.Add(update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(collected);
|
||||
Assert.False(string.IsNullOrWhiteSpace(string.Concat(collected)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTurn_WithPreviousResponseId_PreservesContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("My favorite number is 42. Acknowledge briefly.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("What number did I just tell you?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("42", second.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task MultiTurn_WithConversationId_PreservesContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var conversationId = await this._fixture.CreateConversationAsync();
|
||||
try
|
||||
{
|
||||
var options = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("My favorite color is teal. Acknowledge briefly.", options: options);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("What color did I just tell you?", options: options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("teal", second.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoredFalse_Baseline_DoesNotPersistResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var options = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with the word 'pong'.", options: options);
|
||||
|
||||
// Assert: response returned but the response id is not retrievable from the chain.
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
var responseId = response.ResponseId;
|
||||
Assert.False(string.IsNullOrWhiteSpace(responseId));
|
||||
|
||||
// Attempting to fetch the response should fail because nothing was stored.
|
||||
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => responsesClient.GetResponseAsync(responseId));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task StoredFalse_WithPreviousResponseId_ReadsHistoryButDoesNotAppendAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Turn 1 is stored so the chain head exists.
|
||||
var first = await agent.RunAsync("Remember the number 73. Acknowledge briefly.", session);
|
||||
|
||||
// Turn 2 is stored=false but reads from turn 1 via the same session.
|
||||
var optionsNoStore = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
|
||||
});
|
||||
|
||||
// Act
|
||||
var second = await agent.RunAsync("What number did I just tell you?", session, optionsNoStore);
|
||||
|
||||
// Assert: model received history (knows the number) but the new response is not persisted.
|
||||
Assert.Contains("73", second.Text);
|
||||
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => responsesClient.GetResponseAsync(second.ResponseId!));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var conversationId = await this._fixture.CreateConversationAsync();
|
||||
try
|
||||
{
|
||||
var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
|
||||
var notStored = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
|
||||
});
|
||||
|
||||
// Turn 1 stored, populates the conversation.
|
||||
await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored);
|
||||
var beforeCount = await this._fixture.CountConversationItemsAsync(conversationId);
|
||||
|
||||
// Act: turn 2 reads from conversation but is not appended.
|
||||
var second = await agent.RunAsync("What number did I just tell you?", options: notStored);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("99", second.Text);
|
||||
var afterCount = await this._fixture.CountConversationItemsAsync(conversationId);
|
||||
Assert.Equal(beforeCount, afterCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
|
||||
public async Task StoredTrue_Default_PersistsResponseInChainAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Reply with the word 'ack'.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
var fetched = await responsesClient.GetResponseAsync(response.ResponseId!);
|
||||
Assert.NotNull(fetched.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Instructions_FromContainerDefinition_AreObeyedAsync()
|
||||
{
|
||||
// Arrange: the container side instructions for happy-path enforce a single word reply
|
||||
// (e.g. "Always reply with exactly the single word ECHO."). See TestContainer/Program.cs.
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Say something useful.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("ECHO", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for an MCP backed toolbox: the hosted container connects to a public MCP server
|
||||
/// (the Microsoft Learn MCP endpoint) at startup and exposes its tools to the model.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class McpToolboxHostedAgentTests(McpToolboxHostedAgentFixture fixture)
|
||||
: IClassFixture<McpToolboxHostedAgentFixture>
|
||||
{
|
||||
private readonly McpToolboxHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task McpTool_IsInvokedSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Use the Microsoft Learn MCP tool to look up 'Azure AI Foundry'. Reply with one short paragraph.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
|
||||
"Expected at least one MCP tool invocation in the response messages.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task McpTool_WithStructuredArguments_ReturnsValidResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Use the MCP search tool with the query 'agent framework hosted agents'. Reply with at least one fact.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task McpTool_ProducesUsableResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Tell me one thing about Microsoft Foundry that would only be in MS Learn docs.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
# Foundry.Hosting.IntegrationTests
|
||||
|
||||
Integration tests for `Microsoft.Agents.AI.Foundry.Hosting` against real Foundry hosted agents.
|
||||
|
||||
## How it works
|
||||
|
||||
Each test class is bound to a scenario fixture (e.g. `HappyPathHostedAgentFixture`,
|
||||
`ToolCallingHostedAgentFixture`). On `InitializeAsync` the fixture:
|
||||
|
||||
1. Reads `AZURE_AI_PROJECT_ENDPOINT` and `IT_HOSTED_AGENT_IMAGE` from the environment.
|
||||
2. Targets a stable, scenario keyed agent name (e.g. `it-happy-path`). The agent is
|
||||
provisioned out of band by `scripts/it-bootstrap-agents.ps1`; tests only manage versions.
|
||||
3. Calls `AgentAdministrationClient.CreateAgentVersionAsync` with a `HostedAgentDefinition`
|
||||
that points at the image, sets `IT_SCENARIO=<scenario>` in the container env vars, and
|
||||
adds a per-run `IT_RUN_ID` so each run gets a fresh content-addressed version (Foundry
|
||||
deduplicates versions by definition hash).
|
||||
4. Polls until the agent reports `AgentVersionStatus.Active` (timeout: 5 minutes).
|
||||
5. Patches the agent endpoint with `AgentEndpointConfig` (Responses protocol, version
|
||||
selector pointing 100% at the new version).
|
||||
6. Builds a per-agent `ProjectOpenAIClient` with `AgentName` set on the options (this
|
||||
selects the `/agents/{name}/endpoint/protocols/openai` URL suffix; the cached
|
||||
`projectClient.ProjectOpenAIClient` cannot serve a hosted agent), wraps the
|
||||
`ProjectResponsesClient` as an `AIAgent`, and exposes it via `Agent`.
|
||||
|
||||
On `DisposeAsync` only the version created by this fixture is deleted. The agent itself
|
||||
is intentionally never deleted, because its managed identity must hold the pre-granted
|
||||
`Azure AI User` role on the project scope for inbound inference to succeed.
|
||||
|
||||
The container image is **the same for every scenario**. The `IT_SCENARIO` env var, set on
|
||||
the agent definition by each fixture, drives a `switch` in the test container's
|
||||
`Program.cs` to wire up the scenario specific behavior (tools, toolbox, custom storage,
|
||||
etc.).
|
||||
|
||||
## Required environment variables
|
||||
|
||||
| Variable | Source | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
|
||||
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
|
||||
|
||||
## One-time bootstrap (per Foundry project)
|
||||
|
||||
Hosted agent invocation requires the agent's own managed identity to hold the
|
||||
`Azure AI User` role on the project scope. Because each agent's MI is created when the
|
||||
agent is first provisioned (and recycled on agent delete), the bootstrap creates the
|
||||
six stable scenario agents once and grants the role to each MI. The fixture then only
|
||||
manages versions under those existing agents, so the role grants survive across runs.
|
||||
|
||||
```powershell
|
||||
./scripts/it-bootstrap-agents.ps1 `
|
||||
-ProjectEndpoint "https://<account>.services.ai.azure.com/api/projects/<project>" `
|
||||
-Image "<acr>.azurecr.io/foundry-hosting-it:<tag>"
|
||||
```
|
||||
|
||||
The script is idempotent. It requires Owner or User Access Administrator on the project
|
||||
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
|
||||
running the tests.
|
||||
|
||||
## Building and pushing the test container image
|
||||
|
||||
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
|
||||
Build and push it with:
|
||||
|
||||
```powershell
|
||||
$env:IT_REGISTRY = "<your-acr>.azurecr.io"
|
||||
$env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
|
||||
```
|
||||
|
||||
The script tags the image by content hash of the test container source. If you didn't
|
||||
change anything since the last build, the push is a no op.
|
||||
|
||||
The Foundry project's account MI and project MI both need `AcrPull` on the registry.
|
||||
|
||||
## Running the tests locally
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
|
||||
# IT_HOSTED_AGENT_IMAGE was set above.
|
||||
|
||||
dotnet test dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
|
||||
```
|
||||
|
||||
> **Note:** all tests are currently tagged `[Fact(Skip = ...)]` until end to end smoke
|
||||
> verification has run against a live Foundry deployment. Once a scenario has been
|
||||
> exercised and the assertions stabilized, remove the Skip annotation on its tests.
|
||||
|
||||
All test classes carry `[Trait("Category", "FoundryHostedAgents")]` so the CI workflow can
|
||||
route them to a separate Foundry project than the rest of the integration tests (see
|
||||
`.github/workflows/dotnet-build-and-test.yml`).
|
||||
|
||||
## CI wiring
|
||||
|
||||
The main "Run Integration Tests" step excludes this category. Two extra steps run only on
|
||||
`ubuntu-latest` for this category, gated on `paths-filter.outputs.foundryHostingChanges`
|
||||
so they execute only when the project under test, its dependency chain, the test
|
||||
container, the test fixture, or their tooling changed:
|
||||
|
||||
1. **Build and push Foundry Hosted Agents test container** invokes
|
||||
`scripts/it-build-image.ps1` against `vars.IT_HOSTED_AGENT_REGISTRY`. The image is
|
||||
rebuilt every IT run; its tag is content-hashed across the test container source AND
|
||||
its referenced framework projects (`Microsoft.Agents.AI.Foundry.Hosting`,
|
||||
`Microsoft.Agents.AI.Foundry`, `Microsoft.Agents.AI`, `Microsoft.Agents.AI.Abstractions`),
|
||||
so unchanged content is a `docker push` no-op while any framework code change forces
|
||||
a fresh image. The script pipes its `IT_HOSTED_AGENT_IMAGE=<tag>` line into
|
||||
`$GITHUB_ENV` for the next step.
|
||||
|
||||
2. **Run Foundry Hosted Agents Integration Tests** executes only `--filter-trait
|
||||
"Category=FoundryHostedAgents"` with the env vars below mapped onto the names the
|
||||
fixture reads. `IT_HOSTED_AGENT_IMAGE` is the value just exported by step 1.
|
||||
|
||||
| GitHub env var | Mapped to |
|
||||
| --- | --- |
|
||||
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
|
||||
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
|
||||
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
|
||||
|
||||
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
|
||||
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
|
||||
job in `.github/workflows/dotnet-build-and-test.yml` under `filters.foundryHosting` and
|
||||
must stay in sync with `$hashedDirs` in `scripts/it-build-image.ps1`.
|
||||
|
||||
The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
|
||||
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
|
||||
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
|
||||
|
||||
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
|
||||
human-only operation; CI only adds and deletes versions under existing agents.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| Fixture | `IT_SCENARIO` | Agent name | What it tests |
|
||||
| --- | --- | --- | --- |
|
||||
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. |
|
||||
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
|
||||
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
|
||||
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
|
||||
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
|
||||
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
|
||||
|
||||
The placeholder scenarios will be wired up in the test container `Program.cs` once the
|
||||
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the human in the loop tool approval flow: the container declares an AIFunction
|
||||
/// flagged as requiring approval, and the model raises a <see cref="ToolApprovalRequestContent"/>
|
||||
/// before the tool executes.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolCallingApprovalHostedAgentTests(ToolCallingApprovalHostedAgentFixture fixture)
|
||||
: IClassFixture<ToolCallingApprovalHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolCallingApprovalHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ApprovalRequiredTool_RaisesApprovalRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Run the SendEmail tool with subject='hi' to test@example.com.");
|
||||
|
||||
// Assert
|
||||
var approvalRequest = response.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(approvalRequest);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ApprovalGranted_ToolRunsAndResponseReflectsResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
var first = await agent.RunAsync("Run the SendEmail tool with subject='ok' to test@example.com.", session);
|
||||
var approvalRequest = first.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.First();
|
||||
|
||||
var approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
|
||||
|
||||
// Act
|
||||
var second = await agent.RunAsync([followUp], session);
|
||||
|
||||
// Assert: model received the tool result and produced a final response.
|
||||
Assert.False(string.IsNullOrWhiteSpace(second.Text));
|
||||
var hasFurtherApprovalRequest = second.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.Any();
|
||||
Assert.False(hasFurtherApprovalRequest, "Did not expect another approval request after granting.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ApprovalDenied_ToolDoesNotRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
var first = await agent.RunAsync("Run the SendEmail tool with subject='no' to test@example.com.", session);
|
||||
var approvalRequest = first.Messages
|
||||
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
|
||||
.First();
|
||||
|
||||
var approvalResponse = approvalRequest.CreateResponse(approved: false);
|
||||
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
|
||||
|
||||
// Act
|
||||
var second = await agent.RunAsync([followUp], session);
|
||||
|
||||
// Assert: no FunctionResultContent for SendEmail in the response.
|
||||
Assert.False(string.IsNullOrWhiteSpace(second.Text));
|
||||
var sendEmailResults = second.Messages
|
||||
.SelectMany(m => m.Contents.OfType<FunctionResultContent>())
|
||||
.Where(r => r.CallId == approvalRequest.ToolCall?.CallId);
|
||||
Assert.Empty(sendEmailResults);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that exercise server side tool invocation by a hosted agent. The container
|
||||
/// declares deterministic AIFunctions (e.g. <c>GetUtcNow</c>, <c>Multiply</c>) and the
|
||||
/// model decides whether to call them based on the prompt.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolCallingHostedAgentTests(ToolCallingHostedAgentFixture fixture) : IClassFixture<ToolCallingHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolCallingHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_IsInvokedWhenPromptedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("What is the current UTC date and time? Use the GetUtcNow tool.");
|
||||
|
||||
// Assert: response references a timestamp (very loose check; deterministic-ish).
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
|
||||
"Expected at least one FunctionCallContent in the response messages.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_NotInvokedWhenNotNeededAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Say hello in one word.");
|
||||
|
||||
// Assert: no tool call expected for a simple greeting.
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
var toolCallCount = response.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()).Count();
|
||||
Assert.Equal(0, toolCallCount);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_MultiTurn_RemembersPriorToolResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var first = await agent.RunAsync("Multiply 6 by 7 using the Multiply tool. Reply with the result.", session);
|
||||
Assert.Contains("42", first.Text);
|
||||
|
||||
var second = await agent.RunAsync("What was the result of the last multiplication?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("42", second.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerSideTool_WithArguments_ReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Use the Multiply tool with a=12 and b=11. Reply with just the numeric result.");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("132", response.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Foundry toolbox: the hosted container registers tools via the toolbox API
|
||||
/// (server side), and tests can also add tools client side. The model should be able to
|
||||
/// invoke tools from both sources.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolboxHostedAgentTests(ToolboxHostedAgentFixture fixture) : IClassFixture<ToolboxHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolboxHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerRegisteredToolboxTool_IsCallableAsync()
|
||||
{
|
||||
// Arrange: the container side toolbox registers GetEnvironmentName which returns a constant.
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Call GetEnvironmentName via the toolbox and reply with just the value.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("integration-test", response.Text, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ClientSideAddedToolboxTool_IsListedAndCallableAsync()
|
||||
{
|
||||
// TODO: requires AgentToolboxes API surface. Placeholder asserting the test runs.
|
||||
var agent = this._fixture.Agent;
|
||||
var response = await agent.RunAsync("List all tools you have access to.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ListingTools_ReturnsBothServerAndClientSideEntriesAsync()
|
||||
{
|
||||
// TODO: requires AgentAdministrationClient toolbox listing. Placeholder.
|
||||
var agent = this._fixture.Agent;
|
||||
var response = await agent.RunAsync("Briefly describe what tools are available.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-time bootstrap of stable hosted agents for the Foundry.Hosting.IntegrationTests suite.
|
||||
|
||||
.DESCRIPTION
|
||||
The IT fixture targets stable, scenario-keyed agent names (e.g. it-happy-path) and only
|
||||
manages versions on each test run. The agent itself must already exist AND its managed
|
||||
identity must hold the Azure AI User role on the project scope, otherwise inbound
|
||||
inference calls fail with HTTP 500 PermissionDenied.
|
||||
|
||||
This script idempotently creates each scenario agent (with a placeholder version) and
|
||||
grants Azure AI User on the project to its managed identity. Re-run it safely; existing
|
||||
agents and role assignments are left in place.
|
||||
|
||||
.PARAMETER ProjectEndpoint
|
||||
Foundry project endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>
|
||||
|
||||
.PARAMETER Image
|
||||
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
|
||||
Use the value emitted by scripts/it-build-image.ps1.
|
||||
|
||||
.EXAMPLE
|
||||
./it-bootstrap-agents.ps1 `
|
||||
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
|
||||
-Image "myacr.azurecr.io/foundry-hosting-it:abc123"
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $ProjectEndpoint,
|
||||
[Parameter(Mandatory)] [string] $Image
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Scenarios = @(
|
||||
'happy-path',
|
||||
'tool-calling',
|
||||
'tool-calling-approval',
|
||||
'toolbox',
|
||||
'mcp-toolbox',
|
||||
'custom-storage'
|
||||
)
|
||||
|
||||
# Resolve project ARM scope from the endpoint.
|
||||
$endpointUri = [Uri]$ProjectEndpoint
|
||||
$accountName = $endpointUri.Host.Split('.')[0]
|
||||
$projectName = ($endpointUri.AbsolutePath.TrimEnd('/') -split '/')[-1]
|
||||
$accountInfo = az cognitiveservices account list --query "[?name=='$accountName'].{name:name, rg:resourceGroup, sub:id}" | ConvertFrom-Json
|
||||
if (-not $accountInfo) { throw "Could not find Cognitive Services account '$accountName'." }
|
||||
$rg = $accountInfo[0].rg
|
||||
$sub = ($accountInfo[0].sub -split '/')[2]
|
||||
$projectScope = "/subscriptions/$sub/resourceGroups/$rg/providers/Microsoft.CognitiveServices/accounts/$accountName/projects/$projectName"
|
||||
Write-Host "Project scope: $projectScope"
|
||||
|
||||
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
|
||||
$headers = @{
|
||||
Authorization = "Bearer $tok"
|
||||
'Foundry-Features' = 'HostedAgents=V1Preview'
|
||||
'Content-Type' = 'application/json'
|
||||
}
|
||||
|
||||
foreach ($scenario in $Scenarios) {
|
||||
$agentName = "it-$scenario"
|
||||
Write-Host ""
|
||||
Write-Host "=== $agentName ==="
|
||||
|
||||
# 1. Ensure the agent exists. Create a placeholder version if it doesn't.
|
||||
$agent = $null
|
||||
try {
|
||||
$agent = Invoke-RestMethod -Method GET -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
|
||||
Write-Host " agent exists"
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode -ne 404) { throw }
|
||||
}
|
||||
|
||||
if (-not $agent) {
|
||||
Write-Host " creating placeholder version..."
|
||||
$body = @{
|
||||
definition = @{
|
||||
kind = 'hosted'
|
||||
container_protocol_versions = @(@{ protocol = 'responses'; version = '1.0.0' })
|
||||
cpu = '0.25'
|
||||
memory = '0.5Gi'
|
||||
environment_variables = @{ IT_SCENARIO = $scenario }
|
||||
image = $Image
|
||||
}
|
||||
metadata = @{ enableVnextExperience = 'true' }
|
||||
} | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod -Method POST -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName/versions`?api-version=v1" `
|
||||
-Body $body | Out-Null
|
||||
Start-Sleep 5
|
||||
$agent = Invoke-RestMethod -Method GET -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
|
||||
}
|
||||
|
||||
$principalId = $agent.versions.latest.instance_identity.principal_id
|
||||
Write-Host " agent MI: $principalId"
|
||||
|
||||
# 2. PATCH the agent endpoint to route via @latest if not already configured.
|
||||
# Using @latest means each new version added by the IT fixture automatically becomes the
|
||||
# served version, no per-run PATCH needed (which is good because the strongly-typed
|
||||
# PATCH wrapper is alpha-only on Azure.AI.Projects right now).
|
||||
$hasLatestSelector = $agent.agent_endpoint -and `
|
||||
($agent.agent_endpoint.version_selector.version_selection_rules | Where-Object { $_.agent_version -eq '@latest' })
|
||||
if ($hasLatestSelector) {
|
||||
Write-Host " endpoint already routes via @latest"
|
||||
} else {
|
||||
Write-Host " patching endpoint to route via @latest..."
|
||||
$patchBody = @{
|
||||
agent_endpoint = @{
|
||||
version_selector = @{
|
||||
version_selection_rules = @(@{
|
||||
type = 'FixedRatio'
|
||||
agent_version = '@latest'
|
||||
traffic_percentage = 100
|
||||
})
|
||||
}
|
||||
protocols = @('responses')
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod -Method PATCH -Headers $headers `
|
||||
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1" `
|
||||
-Body $patchBody | Out-Null
|
||||
}
|
||||
|
||||
# 3. Grant Azure AI User on the project scope to the agent MI (idempotent).
|
||||
$existing = az role assignment list --assignee $principalId --scope $projectScope `
|
||||
--query "[?roleDefinitionName=='Azure AI User']" 2>$null | ConvertFrom-Json
|
||||
if ($existing) {
|
||||
Write-Host " role already assigned"
|
||||
} else {
|
||||
Write-Host " granting Azure AI User..."
|
||||
$maxAttempts = 12
|
||||
$granted = $false
|
||||
for ($i = 1; $i -le $maxAttempts; $i++) {
|
||||
$output = az role assignment create `
|
||||
--assignee-object-id $principalId `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role 'Azure AI User' `
|
||||
--scope $projectScope 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$granted = $true
|
||||
break
|
||||
}
|
||||
if ($output -match 'Cannot find user or service principal in graph') {
|
||||
Write-Host " attempt $i/$maxAttempts : MI not yet in AAD graph, retrying in 15s..."
|
||||
Start-Sleep 15
|
||||
continue
|
||||
}
|
||||
throw "az role assignment failed: $output"
|
||||
}
|
||||
if (-not $granted) {
|
||||
throw "MI '$principalId' did not appear in AAD graph after $maxAttempts attempts."
|
||||
}
|
||||
Write-Host " granted (RBAC propagation may take 1-3 minutes)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Wait ~3 minutes after first-time grants before running the tests."
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds and pushes the Foundry.Hosting.IntegrationTests.TestContainer image to a container registry.
|
||||
|
||||
.DESCRIPTION
|
||||
The integration tests in dotnet/tests/Foundry.Hosting.IntegrationTests provision real
|
||||
Foundry hosted agents that point at a container image. This script builds and pushes that
|
||||
image, then emits the IT_HOSTED_AGENT_IMAGE=... line that the tests read from the
|
||||
environment.
|
||||
|
||||
.PARAMETER Registry
|
||||
The container registry login server, e.g. mycompany.azurecr.io. Required. There is no
|
||||
default because every team and every dev may use a different registry.
|
||||
|
||||
.PARAMETER Repository
|
||||
Image repository name within the registry. Defaults to foundry-hosting-it.
|
||||
|
||||
.PARAMETER TestContainerProject
|
||||
Path to the test container csproj. Defaults to the in repo location.
|
||||
|
||||
.EXAMPLE
|
||||
PS> ./scripts/it-build-image.ps1 -Registry mycompany.azurecr.io
|
||||
IT_HOSTED_AGENT_IMAGE=mycompany.azurecr.io/foundry-hosting-it:abc123def456
|
||||
|
||||
.EXAMPLE
|
||||
Local dev, set the env var directly:
|
||||
PS> $env:IT_REGISTRY = "mycompany.azurecr.io"
|
||||
PS> $env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
|
||||
|
||||
.EXAMPLE
|
||||
CI workflow, assumes IT_REGISTRY is set in the environment:
|
||||
- name: Build IT image
|
||||
run: pwsh ./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Tee-Object -FilePath $env:GITHUB_ENV
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Registry,
|
||||
|
||||
[string] $Repository = "foundry-hosting-it",
|
||||
|
||||
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Resolve to the repo root regardless of the caller's PWD so all relative paths used below
|
||||
# (TestContainerProject, the framework src dirs hashed for the image tag) resolve correctly.
|
||||
# This script lives at <repoRoot>/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/.
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../../../..")).Path
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
|
||||
if (-not (Test-Path $TestContainerProject)) {
|
||||
throw "Test container project not found at '$TestContainerProject' (repo root '$RepoRoot')."
|
||||
}
|
||||
|
||||
# Strip any scheme/trailing slash from the registry, then derive the ACR short name.
|
||||
$Registry = $Registry -replace '^https?://', '' -replace '/+$', ''
|
||||
$registryHost = $Registry.Split('.')[0]
|
||||
if ([string]::IsNullOrWhiteSpace($registryHost)) {
|
||||
throw "Could not derive ACR short name from -Registry '$Registry'."
|
||||
}
|
||||
|
||||
# Hash the test container source content AND the source of all referenced framework projects
|
||||
# so any edit (in TestContainer OR in dotnet/src/Microsoft.Agents.AI.Foundry*/) produces a new
|
||||
# tag. The TestContainer image embeds compiled output of those projects, so a framework code
|
||||
# change must invalidate the tag for `docker push` to publish a new layer; a TestContainer-only
|
||||
# hash silently reused stale images on framework edits.
|
||||
#
|
||||
# Keep this list in sync with the `foundryHosting` paths-filter in
|
||||
# .github/workflows/dotnet-build-and-test.yml so CI gating and image tagging cover the same set.
|
||||
$hashedDirs = @(
|
||||
$TestContainerProject,
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting",
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry",
|
||||
"dotnet/src/Microsoft.Agents.AI",
|
||||
"dotnet/src/Microsoft.Agents.AI.Abstractions",
|
||||
"dotnet/src/Microsoft.Agents.AI.Workflows"
|
||||
)
|
||||
$sourceFiles = @()
|
||||
foreach ($dir in $hashedDirs) {
|
||||
if (Test-Path $dir) {
|
||||
$sourceFiles += @(git -c core.quotepath=false ls-files -- $dir)
|
||||
}
|
||||
}
|
||||
if ($sourceFiles.Count -eq 0) {
|
||||
throw "No tracked files found under any of: $($hashedDirs -join ', ')"
|
||||
}
|
||||
$fileHashes = git hash-object -- $sourceFiles
|
||||
$shaInput = ($fileHashes -join "`n" | git hash-object --stdin).Trim()
|
||||
$tag = $shaInput.Substring(0, 12)
|
||||
$image = "$Registry/$Repository`:$tag"
|
||||
|
||||
Write-Host "Publishing $TestContainerProject ..." -ForegroundColor Cyan
|
||||
$out = Join-Path $TestContainerProject "out"
|
||||
if (Test-Path $out) {
|
||||
Remove-Item -Recurse -Force $out
|
||||
}
|
||||
|
||||
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Write-Host "Building $image ..." -ForegroundColor Cyan
|
||||
docker build -t $image -f (Join-Path $TestContainerProject "Dockerfile") $TestContainerProject | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "docker build failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Write-Host "Pushing $image ..." -ForegroundColor Cyan
|
||||
az acr login -n $registryHost | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "az acr login failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
docker push $image | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "docker push failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
# Emit the env var line for shells / CI consumption.
|
||||
"IT_HOSTED_AGENT_IMAGE=$image"
|
||||
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
Reference in New Issue
Block a user