File: FoundryHostedAgentDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable AAIP001 // Toolbox APIs are experimental.
 
using System.ClientModel.Primitives;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Resources;
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Azure;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for deploying Aspire applications with Foundry Hosted Agents.
/// </summary>
public sealed class FoundryHostedAgentDeploymentTests(ITestOutputHelper output)
{
    // Timeout set to 45 minutes to allow for Azure AI Foundry provisioning and model deployment.
    // Foundry deployments can take longer than standard ACA due to AI resource provisioning.
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45);
 
    // This scenario deploys twice; its two phase limits total 50 minutes before setup and inspection.
    private static readonly TimeSpan s_toolboxTestTimeout = TimeSpan.FromMinutes(70);
 
    [Fact]
    [ActiveIssue("https://github.com/microsoft/aspire/issues/16330")]
    public async Task DeployFoundryHostedAgentToAzure()
    {
        using var cts = new CancellationTokenSource(s_testTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
        var cancellationToken = linkedCts.Token;
 
        await DeployFoundryHostedAgentToAzureCore(cancellationToken);
    }
 
    [Fact]
    public async Task DeployFoundryToolboxToAzure()
    {
        using var cts = new CancellationTokenSource(s_toolboxTestTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
        var cancellationToken = linkedCts.Token;
 
        var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId();
        if (string.IsNullOrEmpty(subscriptionId))
        {
            Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION.");
        }
 
        if (!AzureAuthenticationHelpers.IsAzureAuthAvailable())
        {
            if (DeploymentE2ETestHelpers.IsRunningInCI)
            {
                Assert.Fail("Azure authentication not available in CI. Check OIDC configuration.");
            }
            else
            {
                Assert.Skip("Azure authentication not available. Run 'az login' to authenticate.");
            }
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var startTime = DateTime.UtcNow;
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("foundry-toolbox");
        const string projectName = "FoundryToolbox";
 
        try
        {
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
            var pendingRun = terminal.RunAsync(cancellationToken);
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            await auto.PrepareEnvironmentAsync(workspace, counter);
            await auto.InstallCurrentBuildAspireCliAsync(counter, output);
            await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
            await auto.TypeAsync($"cd {projectName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync("aspire add Aspire.Hosting.Foundry");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter);
 
            var appHostFilePath = Path.Combine(
                workspace.WorkspaceRoot.FullName,
                projectName,
                $"{projectName}.AppHost",
                "AppHost.cs");
            var appHostContent = File.ReadAllText(appHostFilePath);
            appHostContent = "using Aspire.Hosting.Foundry;\n" + appHostContent;
            appHostContent = appHostContent.Replace(
                "builder.Build().Run();",
                """
                var foundry = builder.AddFoundry("aif-myfoundry");
                var foundryProject = foundry.AddProject("proj-myproject");
                var search = builder.AddAzureSearch("search");
 
                foundryProject.AddToolbox("field-tools")
                    .WithDescription("Tools for field technicians.")
                    .WithWebSearchTool("web-search", "Search the public web.")
                    .WithMcpTool(
                        "microsoft-learn",
                        "https://learn.microsoft.com/api/mcp",
                        new FoundryToolboxMcpToolOptions
                        {
                            ServerDescription = "Search Microsoft Learn.",
                            ApprovalPolicy = new()
                            {
                                Global = FoundryToolboxMcpGlobalApprovalMode.Always
                            }
                        })
                    .WithAISearchTool(
                        "knowledge-base",
                        search,
                        "docs",
                        "Search the internal knowledge base.");
 
                builder.Build().Run();
                """);
            File.WriteAllText(appHostFilePath, appHostContent);
 
            await auto.TypeAsync($"cd {projectName}.AppHost");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
            await auto.TypeAsync(
                $"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=swedencentral && export AZURE__RESOURCEGROUP={resourceGroupName}" +
                $" && export AZURE__SUBSCRIPTIONID={subscriptionId}" +
                " && export AZURE__TENANTID=$(az account show --query tenantId -o tsv)");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // The first deployment creates a Toolbox version; the second must reconcile to the same
            // immutable version rather than producing another one.
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(35), counter: counter);
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            var credential = AzureAuthenticationHelpers.GetAzureCredential();
            var resources = await GetToolboxTestResourcesAsync(
                subscriptionId,
                resourceGroupName,
                credential,
                cancellationToken);
            await EnsureSearchIndexExistsAsync(resources, credential, cancellationToken);
            var firstDeployment = await InspectToolboxAsync(
                resources.ProjectEndpoint,
                credential,
                cancellationToken);
            AssertToolboxDefinition(firstDeployment);
            Assert.Equal(1, firstDeployment.VersionCount);
 
            var discoveredTools = await ListToolboxToolsAsync(
                resources.ProjectEndpoint,
                credential,
                cancellationToken);
            Assert.Contains("knowledge-base", discoveredTools);
 
            // The first pipeline banner remains visible, so clear it before waiting for the redeploy.
            await auto.TypeAsync("clear");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(15), counter: counter);
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            var secondDeployment = await InspectToolboxAsync(
                resources.ProjectEndpoint,
                credential,
                cancellationToken);
            Assert.Equal(firstDeployment.DefaultVersion, secondDeployment.DefaultVersion);
            Assert.Equal(firstDeployment.ConfigurationHash, secondDeployment.ConfigurationHash);
            Assert.Equal(firstDeployment.VersionCount, secondDeployment.VersionCount);
 
            await auto.TypeAsync(
                $"az group show -n \"{resourceGroupName}\" --query name -o tsv");
            await auto.EnterAsync();
            await auto.WaitUntilTextAsync(resourceGroupName, timeout: TimeSpan.FromMinutes(2));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
            await pendingRun;
 
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployFoundryToolboxToAzure),
                resourceGroupName,
                new Dictionary<string, string>(),
                DateTime.UtcNow - startTime);
        }
        catch (Exception ex)
        {
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployFoundryToolboxToAzure),
                resourceGroupName,
                ex.Message,
                ex.StackTrace);
            throw;
        }
        finally
        {
            TriggerCleanupResourceGroup(resourceGroupName, output);
            DeploymentReporter.ReportCleanupStatus(
                resourceGroupName,
                success: true,
                "Cleanup triggered (fire-and-forget)");
        }
    }
 
    private static async Task<ToolboxTestResources> GetToolboxTestResourcesAsync(
        string subscriptionId,
        string resourceGroupName,
        TokenCredential credential,
        CancellationToken cancellationToken)
    {
        var managementToken = await credential.GetTokenAsync(
            new TokenRequestContext(["https://management.azure.com/.default"]),
            cancellationToken);
        using var client = new HttpClient();
        var resourcesUri = new Uri(
            $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/resources?api-version=2021-04-01");
        using var resourcesRequest = new HttpRequestMessage(HttpMethod.Get, resourcesUri);
        resourcesRequest.Headers.Authorization =
            new AuthenticationHeaderValue("Bearer", managementToken.Token);
        using var resourcesResponse = await client.SendAsync(resourcesRequest, cancellationToken);
        resourcesResponse.EnsureSuccessStatusCode();
        using var resourcesDocument = JsonDocument.Parse(
            await resourcesResponse.Content.ReadAsStringAsync(cancellationToken));
 
        var resources = resourcesDocument.RootElement.GetProperty("value").EnumerateArray().ToArray();
        var project = resources.Single(resource =>
            string.Equals(
                resource.GetProperty("type").GetString(),
                "Microsoft.CognitiveServices/accounts/projects",
                StringComparison.OrdinalIgnoreCase));
        var search = resources.Single(resource =>
            string.Equals(
                resource.GetProperty("type").GetString(),
                "Microsoft.Search/searchServices",
                StringComparison.OrdinalIgnoreCase));
 
        var projectId = project.GetProperty("id").GetString()
            ?? throw new InvalidOperationException("The deployed Foundry project did not have a resource ID.");
        using var projectRequest = new HttpRequestMessage(
            HttpMethod.Get,
            $"https://management.azure.com{projectId}?api-version=2025-06-01");
        projectRequest.Headers.Authorization =
            new AuthenticationHeaderValue("Bearer", managementToken.Token);
        using var projectResponse = await client.SendAsync(projectRequest, cancellationToken);
        projectResponse.EnsureSuccessStatusCode();
        using var projectDocument = JsonDocument.Parse(
            await projectResponse.Content.ReadAsStringAsync(cancellationToken));
        var projectEndpoint = projectDocument.RootElement
            .GetProperty("properties")
            .GetProperty("endpoints")
            .GetProperty("AI Foundry API")
            .GetString();
 
        return new(
            new Uri(projectEndpoint
                ?? throw new InvalidOperationException("The deployed Foundry project did not expose an endpoint.")),
            search.GetProperty("name").GetString()
                ?? throw new InvalidOperationException("The deployed Search service did not have a name."),
            search.GetProperty("id").GetString()
                ?? throw new InvalidOperationException("The deployed Search service did not have a resource ID."));
    }
 
    private static async Task EnsureSearchIndexExistsAsync(
        ToolboxTestResources resources,
        TokenCredential credential,
        CancellationToken cancellationToken)
    {
        var managementToken = await credential.GetTokenAsync(
            new TokenRequestContext(["https://management.azure.com/.default"]),
            cancellationToken);
        var principalId = GetPrincipalId(managementToken.Token);
        using var client = new HttpClient();
        using var roleAssignmentRequest = new HttpRequestMessage(
            HttpMethod.Put,
            $"https://management.azure.com{resources.SearchResourceId}/providers/Microsoft.Authorization/roleAssignments/{Guid.NewGuid():D}?api-version=2022-04-01");
        roleAssignmentRequest.Headers.Authorization =
            new AuthenticationHeaderValue("Bearer", managementToken.Token);
        roleAssignmentRequest.Content = JsonContent.Create(new
        {
            properties = new
            {
                roleDefinitionId =
                    $"/subscriptions/{AzureAuthenticationHelpers.GetSubscriptionId()}/providers/Microsoft.Authorization/roleDefinitions/7ca78c08-252a-4471-8644-bb5ff32d4ba0",
                principalId
            }
        });
        using var roleAssignmentResponse = await client.SendAsync(
            roleAssignmentRequest,
            cancellationToken);
        roleAssignmentResponse.EnsureSuccessStatusCode();
 
        var indexClient = new SearchIndexClient(
            new Uri($"https://{resources.SearchServiceName}.search.windows.net"),
            credential);
        var index = new SearchIndex("docs")
        {
            Fields =
            {
                new SimpleField("id", SearchFieldDataType.String)
                {
                    IsKey = true,
                    IsFilterable = true
                },
                new SearchableField("content")
            }
        };
        using var rbacPropagationCancellation =
            CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        rbacPropagationCancellation.CancelAfter(TimeSpan.FromMinutes(10));
        try
        {
            while (true)
            {
                try
                {
                    await indexClient.CreateOrUpdateIndexAsync(
                        index,
                        cancellationToken: rbacPropagationCancellation.Token);
                    break;
                }
                catch (RequestFailedException ex) when (ex.Status == 403)
                {
                    // Azure RBAC propagation can take up to ten minutes after the test principal
                    // receives Search Service Contributor on the newly provisioned service.
                    // See https://learn.microsoft.com/azure/role-based-access-control/troubleshooting#role-assignment-changes-are-not-being-detected.
                    await Task.Delay(
                        TimeSpan.FromSeconds(5),
                        rbacPropagationCancellation.Token);
                }
            }
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            throw new TimeoutException(
                "Search Service Contributor did not propagate within ten minutes.");
        }
    }
 
    private static string GetPrincipalId(string accessToken)
    {
        var segments = accessToken.Split('.');
        if (segments.Length < 2)
        {
            throw new InvalidOperationException("The Azure access token was not a JWT.");
        }
 
        var payload = segments[1].Replace('-', '+').Replace('_', '/');
        payload = payload.PadRight(payload.Length + ((4 - payload.Length % 4) % 4), '=');
        using var document = JsonDocument.Parse(Convert.FromBase64String(payload));
        return document.RootElement.GetProperty("oid").GetString()
            ?? throw new InvalidOperationException("The Azure access token did not contain an object ID.");
    }
 
    private static async Task<ToolboxDeploymentSnapshot> InspectToolboxAsync(
        Uri projectEndpoint,
        TokenCredential credential,
        CancellationToken cancellationToken)
    {
        var options = new AIProjectClientOptions();
        options.AddPolicy(new ToolboxFeaturesPolicy(), PipelinePosition.PerCall);
        var projectClient = new AIProjectClient(projectEndpoint, credential, options);
        var toolboxes = projectClient.AgentAdministrationClient.GetAgentToolboxes();
        var toolbox = (await toolboxes.GetToolboxAsync("field-tools", cancellationToken)).Value;
        var versions = new List<ToolboxVersion>();
        await foreach (var version in toolboxes.GetToolboxVersionsAsync(
            "field-tools",
            cancellationToken: cancellationToken))
        {
            versions.Add(version);
        }
 
        var defaultVersion = (await toolboxes.GetToolboxVersionAsync(
            "field-tools",
            toolbox.DefaultVersion,
            cancellationToken)).Value;
        var configurationHash = defaultVersion.Metadata["aspire-configuration-hash"];
        var serializedTools = defaultVersion.Tools
            .Select(SerializeTool)
            .ToArray();
 
        return new(
            toolbox.DefaultVersion,
            configurationHash,
            versions.Count,
            defaultVersion.Description,
            new Dictionary<string, string>(defaultVersion.Metadata, StringComparer.Ordinal),
            serializedTools);
    }
 
    private static JsonElement SerializeTool(ProjectsAgentTool tool)
    {
        using var document = JsonDocument.Parse(ModelReaderWriter.Write(
            tool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default));
 
        return document.RootElement.Clone();
    }
 
    private static void AssertToolboxDefinition(ToolboxDeploymentSnapshot snapshot)
    {
        Assert.Equal("Tools for field technicians.", snapshot.Description);
        Assert.Equal("Aspire.Hosting.Foundry", snapshot.Metadata["aspire-managed-by"]);
        Assert.Equal("1", snapshot.Metadata["aspire-schema-version"]);
        Assert.Equal(3, snapshot.Tools.Count);
 
        var webSearch = snapshot.Tools.Single(tool =>
            tool.GetProperty("type").GetString() == "web_search");
        Assert.Equal("web-search", webSearch.GetProperty("name").GetString());
        Assert.Equal("Search the public web.", webSearch.GetProperty("description").GetString());
 
        var mcp = snapshot.Tools.Single(tool =>
            tool.GetProperty("type").GetString() == "mcp");
        Assert.Equal("microsoft-learn", mcp.GetProperty("server_label").GetString());
        Assert.Equal(
            "https://learn.microsoft.com/api/mcp",
            mcp.GetProperty("server_url").GetString());
        Assert.Equal("Search Microsoft Learn.", mcp.GetProperty("server_description").GetString());
        Assert.Equal("always", mcp.GetProperty("require_approval").GetString());
 
        var search = snapshot.Tools.Single(tool =>
            tool.GetProperty("type").GetString() == "azure_ai_search");
        Assert.Equal(
            "docs",
            search.GetProperty("azure_ai_search")
                .GetProperty("indexes")[0]
                .GetProperty("index_name")
                .GetString());
    }
 
    private static async Task<IReadOnlyList<string>> ListToolboxToolsAsync(
        Uri projectEndpoint,
        TokenCredential credential,
        CancellationToken cancellationToken)
    {
        var accessToken = await credential.GetTokenAsync(
            new TokenRequestContext(["https://ai.azure.com/.default"]),
            cancellationToken);
        var endpoint = new Uri(
            projectEndpoint,
            $"{projectEndpoint.AbsolutePath.TrimEnd('/')}/toolboxes/field-tools/mcp?api-version=v1");
        using var client = new HttpClient();
 
        var initialize = await SendMcpRequestAsync(
            client,
            endpoint,
            accessToken.Token,
            sessionId: null,
            protocolVersion: null,
            """
            {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"Aspire.Deployment.EndToEnd.Tests","version":"1.0"}}}
            """,
            cancellationToken);
        var negotiatedProtocol = initialize.Result
            .GetProperty("protocolVersion")
            .GetString();
        Assert.False(string.IsNullOrEmpty(negotiatedProtocol));
 
        await SendMcpRequestAsync(
            client,
            endpoint,
            accessToken.Token,
            initialize.SessionId,
            negotiatedProtocol,
            """{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""",
            cancellationToken);
        using var discoveryCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        discoveryCancellation.CancelAfter(TimeSpan.FromMinutes(2));
        try
        {
            var requestId = 2;
            while (true)
            {
                var toolNames = new HashSet<string>(StringComparer.Ordinal);
                string? cursor = null;
                var retryDiscovery = false;
                do
                {
                    McpResponse tools;
                    try
                    {
                        tools = await SendMcpRequestAsync(
                            client,
                            endpoint,
                            accessToken.Token,
                            initialize.SessionId,
                            negotiatedProtocol,
                            CreateToolsListPayload(requestId++, cursor),
                            discoveryCancellation.Token);
                    }
                    catch (HttpRequestException ex)
                        when (ex.StatusCode == System.Net.HttpStatusCode.InternalServerError)
                    {
                        // Foundry documents HTTP 500 from tools/list as transient while tool
                        // discovery converges immediately after Toolbox provisioning.
                        // See https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox#troubleshooting.
                        retryDiscovery = true;
                        break;
                    }
 
                    foreach (var tool in tools.Result.GetProperty("tools").EnumerateArray())
                    {
                        toolNames.Add(tool.GetProperty("name").GetString()
                            ?? throw new InvalidOperationException("A discovered MCP tool did not have a name."));
                    }
 
                    cursor = tools.Result.TryGetProperty("nextCursor", out var nextCursor)
                        ? nextCursor.GetString()
                        : null;
                }
                while (!string.IsNullOrEmpty(cursor));
 
                if (!retryDiscovery && toolNames.Contains("knowledge-base"))
                {
                    return toolNames.ToArray();
                }
 
                // Toolbox tool discovery is eventually consistent immediately after provisioning.
                await Task.Delay(TimeSpan.FromSeconds(5), discoveryCancellation.Token);
            }
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            throw new TimeoutException(
                "Foundry Toolbox did not discover the 'knowledge-base' Azure AI Search tool within two minutes.");
        }
    }
 
    private static string CreateToolsListPayload(int requestId, string? cursor)
    {
        // MCP tools/list pagination sends an opaque cursor in the next request:
        //   {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"opaque"}}
        using var stream = new MemoryStream();
        using (var writer = new Utf8JsonWriter(stream))
        {
            writer.WriteStartObject();
            writer.WriteString("jsonrpc", "2.0");
            writer.WriteNumber("id", requestId);
            writer.WriteString("method", "tools/list");
            writer.WriteStartObject("params");
            if (cursor is not null)
            {
                writer.WriteString("cursor", cursor);
            }
            writer.WriteEndObject();
            writer.WriteEndObject();
        }
 
        return Encoding.UTF8.GetString(stream.ToArray());
    }
 
    private static async Task<McpResponse> SendMcpRequestAsync(
        HttpClient client,
        Uri endpoint,
        string accessToken,
        string? sessionId,
        string? protocolVersion,
        string payload,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        request.Headers.Add("Foundry-Features", "Toolboxes=V1Preview");
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
        if (!string.IsNullOrEmpty(sessionId))
        {
            request.Headers.Add("Mcp-Session-Id", sessionId);
        }
        if (!string.IsNullOrEmpty(protocolVersion))
        {
            request.Headers.Add("MCP-Protocol-Version", protocolVersion);
        }
        request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
        using var requestDocument = JsonDocument.Parse(payload);
        var expectedId = requestDocument.RootElement.TryGetProperty("id", out var requestId)
            ? requestId.GetInt32()
            : (int?)null;
 
        using var response = await client.SendAsync(request, cancellationToken);
        response.EnsureSuccessStatusCode();
        var responsePayload = await response.Content.ReadAsStringAsync(cancellationToken);
        var responseSessionId = response.Headers.TryGetValues("Mcp-Session-Id", out var values)
            ? values.Single()
            : sessionId;
 
        if (string.IsNullOrWhiteSpace(responsePayload) || expectedId is null)
        {
            return new(default, responseSessionId);
        }
 
        // Streamable HTTP may return either one JSON document or SSE frames such as:
        //   event: message
        //   data: {"jsonrpc":"2.0","id":1,"result":{...}}
        var responseMessages = responsePayload.TrimStart().StartsWith('{')
            ? [responsePayload]
            : responsePayload.Split('\n', StringSplitOptions.TrimEntries)
                .Where(line => line.StartsWith("data:", StringComparison.Ordinal))
                .Select(line => line["data:".Length..].Trim());
        JsonElement? matchingResponse = null;
        foreach (var responseMessage in responseMessages)
        {
            using var candidate = JsonDocument.Parse(responseMessage);
            if (candidate.RootElement.TryGetProperty("id", out var responseId) &&
                responseId.ValueKind == JsonValueKind.Number &&
                responseId.GetInt32() == expectedId)
            {
                matchingResponse = candidate.RootElement.Clone();
                break;
            }
        }
 
        if (matchingResponse is null)
        {
            throw new InvalidOperationException(
                $"The MCP response did not contain JSON-RPC response ID {expectedId}.");
        }
 
        if (matchingResponse.Value.TryGetProperty("error", out var error))
        {
            throw new InvalidOperationException($"MCP request failed: {error.GetRawText()}");
        }
 
        var result = matchingResponse.Value.TryGetProperty("result", out var resultElement)
            ? resultElement.Clone()
            : default;
        return new(result, responseSessionId);
    }
 
    private sealed record ToolboxTestResources(
        Uri ProjectEndpoint,
        string SearchServiceName,
        string SearchResourceId);
 
    private sealed record ToolboxDeploymentSnapshot(
        string DefaultVersion,
        string ConfigurationHash,
        int VersionCount,
        string Description,
        IReadOnlyDictionary<string, string> Metadata,
        IReadOnlyList<JsonElement> Tools);
 
    private sealed record McpResponse(JsonElement Result, string? SessionId);
 
    private sealed class ToolboxFeaturesPolicy : PipelinePolicy
    {
        public override void Process(
            PipelineMessage message,
            IReadOnlyList<PipelinePolicy> pipeline,
            int currentIndex)
        {
            message.Request.Headers.Add("Foundry-Features", "Toolboxes=V1Preview");
            ProcessNext(message, pipeline, currentIndex);
        }
 
        public override ValueTask ProcessAsync(
            PipelineMessage message,
            IReadOnlyList<PipelinePolicy> pipeline,
            int currentIndex)
        {
            message.Request.Headers.Add("Foundry-Features", "Toolboxes=V1Preview");
            return ProcessNextAsync(message, pipeline, currentIndex);
        }
    }
 
    private async Task DeployFoundryHostedAgentToAzureCore(CancellationToken cancellationToken)
    {
        // Validate prerequisites
        var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId();
        if (string.IsNullOrEmpty(subscriptionId))
        {
            Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION.");
        }
 
        if (!AzureAuthenticationHelpers.IsAzureAuthAvailable())
        {
            if (DeploymentE2ETestHelpers.IsRunningInCI)
            {
                Assert.Fail("Azure authentication not available in CI. Check OIDC configuration.");
            }
            else
            {
                Assert.Skip("Azure authentication not available. Run 'az login' to authenticate.");
            }
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var startTime = DateTime.UtcNow;
        var deploymentUrls = new Dictionary<string, string>();
        // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt]
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("foundry-agent");
        var projectName = "FoundryAgent";
 
        output.WriteLine($"Test: {nameof(DeployFoundryHostedAgentToAzure)}");
        output.WriteLine($"Project Name: {projectName}");
        output.WriteLine($"Resource Group: {resourceGroupName}");
        output.WriteLine($"Subscription: {subscriptionId[..8]}...");
        output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}");
 
        try
        {
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
            var pendingRun = terminal.RunAsync(cancellationToken);
 
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            // Step 1: Prepare environment
            output.WriteLine("Step 1: Preparing environment...");
            await auto.PrepareEnvironmentAsync(workspace, counter);
 
            // Step 2: Set up CLI environment
            await auto.InstallCurrentBuildAspireCliAsync(counter, output);
 
            // Step 3: Create starter project using aspire new (for basic AppHost scaffold)
            output.WriteLine("Step 3: Creating starter project...");
            await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
 
            // Step 4: Navigate to project directory
            output.WriteLine("Step 4: Navigating to project directory...");
            await auto.TypeAsync($"cd {projectName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 5: Add Aspire.Hosting.Foundry package to the AppHost
            output.WriteLine("Step 5: Adding Foundry hosting package...");
            await auto.TypeAsync("aspire add Aspire.Hosting.Foundry");
            await auto.EnterAsync();
 
            // aspire add may show a version selection prompt
            await auto.WaitForAspireAddCompletionAsync(counter);
 
            // Step 6: Create a dedicated .NET hosted agent project
            // WithComputeEnvironment requires a proper agent application, not a standard apiservice.
            output.WriteLine("Step 6: Creating .NET hosted agent project...");
            var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
            var hostedAgentDir = Path.Combine(projectDir, "DotNetHostedAgent");
            var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
 
            Directory.CreateDirectory(hostedAgentDir);
 
            // Write minimal hosted agent .csproj.
            // The package set and explicit versions below must stay in sync with
            // playground/FoundryAgents/DotNetHostedAgent/DotNetHostedAgent.csproj.
            // This project is materialized at test runtime outside the repo, so it does not
            // participate in central package management; the playground's `VersionOverride`
            // values become explicit `Version` values here. Update both files together.
            File.WriteAllText(Path.Combine(hostedAgentDir, "DotNetHostedAgent.csproj"), """
                <Project Sdk="Microsoft.NET.Sdk.Web">
                  <PropertyGroup>
                    <TargetFramework>net10.0</TargetFramework>
                    <Nullable>enable</Nullable>
                    <ImplicitUsings>enable</ImplicitUsings>
                    <!-- Suppress experimental API warnings from Agent Framework Foundry packages -->
                    <NoWarn>$(NoWarn);OPENAI001;MAIF001;MAAI001</NoWarn>
                  </PropertyGroup>
                  <ItemGroup>
                    <PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.3" />
                    <PackageReference Include="Azure.Identity" Version="1.21.0" />
                    <PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.12.0-preview.260629.1" />
                    <PackageReference Include="Microsoft.Extensions.AI" Version="10.7.0" />
                    <PackageReference Include="ModelContextProtocol" Version="1.1.0" />
                    <PackageReference Include="Azure.Core" Version="1.59.0" />
                  </ItemGroup>
                </Project>
                """);
 
            // Write minimal hosted agent Program.cs.
            // Mirrors playground/FoundryAgents/DotNetHostedAgent/Program.cs: reads the
            // Foundry project + chat connection strings, builds an AIProjectClient-backed
            // AIAgent, and hosts it as a Foundry Responses endpoint on DEFAULT_AD_PORT.
            File.WriteAllText(Path.Combine(hostedAgentDir, "Program.cs"), """
                using System.ComponentModel;
                using System.Data.Common;
                using Azure.AI.Projects;
                using Azure.Identity;
                using Microsoft.Agents.AI;
                using Microsoft.Agents.AI.Foundry.Hosting;
                using Microsoft.Extensions.AI;
 
                string projectConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings__projmyproject")
                    ?? throw new InvalidOperationException("ConnectionStrings__projmyproject is not set.");
 
                string chatConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings__chat")
                    ?? throw new InvalidOperationException("ConnectionStrings__chat is not set.");
 
                DbConnectionStringBuilder projectConnectionBuilder = new() { ConnectionString = projectConnectionString };
                DbConnectionStringBuilder chatConnectionBuilder = new() { ConnectionString = chatConnectionString };
 
                string projectEndpoint = GetRequiredConnectionValue(projectConnectionBuilder, "Endpoint");
                string deploymentName = GetRequiredConnectionValue(chatConnectionBuilder, "Deployment");
 
                if (!Uri.TryCreate(projectEndpoint, UriKind.Absolute, out Uri? projectUri) || projectUri is null)
                {
                    throw new InvalidOperationException("ConnectionStrings__projmyproject contains an invalid Endpoint value.");
                }
 
                [Description("Get a weather forecast")]
                string GetWeatherForecast() => "Sunny, 25°C";
 
                DefaultAzureCredential credential = new();
 
                AIAgent agent = new AIProjectClient(projectUri, credential)
                    .AsAIAgent(
                        model: deploymentName,
                        name: "WeatherAgent",
                        instructions: "You are the Weather Intelligence Agent.",
                        tools: [AIFunctionFactory.Create(GetWeatherForecast)]);
 
                // Bind to the port allocated by Aspire via the DEFAULT_AD_PORT environment variable.
                string port = Environment.GetEnvironmentVariable("DEFAULT_AD_PORT") ?? "8088";
 
                var builder = WebApplication.CreateBuilder(args);
                builder.WebHost.UseUrls($"http://+:{port}");
                builder.Services.AddFoundryResponses(agent);
 
                var app = builder.Build();
 
                app.MapFoundryResponses();
                app.MapGet("/liveness", () => Results.Ok("Healthy"));
                app.MapGet("/readiness", () => Results.Ok("Ready"));
 
                app.Run();
 
                static string GetRequiredConnectionValue(DbConnectionStringBuilder connectionBuilder, string key)
                {
                    if (!connectionBuilder.TryGetValue(key, out object? rawValue) || rawValue is null)
                    {
                        throw new InvalidOperationException($"Connection string is missing '{key}'.");
                    }
 
                    string? value = rawValue.ToString();
 
                    if (string.IsNullOrWhiteSpace(value))
                    {
                        throw new InvalidOperationException($"Connection string has an empty '{key}' value.");
                    }
 
                    return value;
                }
                """);
 
            // Write Dockerfile for the hosted agent
            File.WriteAllText(Path.Combine(hostedAgentDir, ".dockerignore"), """
                bin/
                obj/
                """);
 
            output.WriteLine($"Created hosted agent project at: {hostedAgentDir}");
 
            // Step 7: Add hosted agent to the solution and add project reference from AppHost
            output.WriteLine("Step 7: Adding hosted agent to solution...");
            await auto.TypeAsync($"dotnet sln add DotNetHostedAgent/DotNetHostedAgent.csproj");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            await auto.TypeAsync($"dotnet add {projectName}.AppHost/{projectName}.AppHost.csproj reference DotNetHostedAgent/DotNetHostedAgent.csproj");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            // Step 8: Modify AppHost.cs to wire up Foundry + hosted agent
            // Replace the standard starter template AppHost with Foundry-based configuration.
            var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
            output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}");
 
            var appHostContent = File.ReadAllText(appHostFilePath);
 
            // Add the Foundry using directive
            appHostContent = "using Aspire.Hosting.Foundry;\n" + appHostContent;
 
            // Insert Foundry resources before builder.Build().Run();
            appHostContent = appHostContent.Replace(
                "builder.Build().Run();",
                """
                var foundry = builder.AddFoundry("aif-myfoundry");
                var foundryProject = foundry.AddProject("proj-myproject");
                var chat = foundryProject.AddModelDeployment("chat", FoundryModel.OpenAI.Gpt41);
 
                builder.AddProject<Projects.DotNetHostedAgent>("dotnet-hosted-agent")
                    .WithReference(chat).WaitFor(chat)
                    .AsHostedAgent(foundryProject, HostedAgentProtocol.Responses, "2.0.0");
 
                builder.Build().Run();
                """);
 
            File.WriteAllText(appHostFilePath, appHostContent);
            output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
 
            // Step 9: Navigate to AppHost project directory
            output.WriteLine("Step 9: Navigating to AppHost directory...");
            await auto.TypeAsync($"cd {projectName}.AppHost");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 10: Set environment variables for deployment
            // - Unset ASPIRE_PLAYGROUND to avoid conflicts
            // - Set Azure location
            // - Set AZURE__RESOURCEGROUP to use our unique resource group name
            await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 11: Deploy to Azure using aspire deploy
            output.WriteLine("Step 11: Starting Foundry Hosted Agent deployment...");
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            // Wait for pipeline to complete successfully
            // Foundry deployments may take longer due to AI resource provisioning
            await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(35));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // Step 12: Verify deployed resources exist in the resource group
            output.WriteLine("Step 12: Verifying deployed resources...");
            await auto.TypeAsync(
                $"RG_NAME=\"{resourceGroupName}\" && " +
                "echo \"Resource group: $RG_NAME\" && " +
                "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
                "resources=$(az resource list -g \"$RG_NAME\" -o table 2>/dev/null) && " +
                "echo \"$resources\" && " +
                "if [ -z \"$resources\" ]; then echo \"❌ No resources found in resource group\"; exit 1; fi && " +
                "echo \"✅ Resources found in resource group\"");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // Step 13: Exit terminal
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
 
            await pendingRun;
 
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"Deployment completed in {duration}");
 
            // Report success
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployFoundryHostedAgentToAzure),
                resourceGroupName,
                deploymentUrls,
                duration);
 
            output.WriteLine("✅ Test passed!");
        }
        catch (Exception ex)
        {
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"❌ Test failed after {duration}: {ex.Message}");
 
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployFoundryHostedAgentToAzure),
                resourceGroupName,
                ex.Message,
                ex.StackTrace);
 
            throw;
        }
        finally
        {
            // Clean up the resource group we created
            output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}");
            TriggerCleanupResourceGroup(resourceGroupName, output);
            DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)");
        }
    }
 
    /// <summary>
    /// Triggers cleanup of a specific resource group.
    /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources.
    /// </summary>
    private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output)
    {
        var process = new System.Diagnostics.Process
        {
            StartInfo = new System.Diagnostics.ProcessStartInfo
            {
                FileName = "az",
                Arguments = $"group delete --name {resourceGroupName} --yes --no-wait",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            }
        };
 
        try
        {
            process.Start();
            output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}");
        }
        catch (Exception ex)
        {
            output.WriteLine($"Failed to trigger cleanup: {ex.Message}");
        }
    }
}