File: HostedAgentExtensionTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Foundry.Tests\Aspire.Hosting.Foundry.Tests.csproj (Aspire.Hosting.Foundry.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 ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPROJECTS001
 
using System.Net;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Foundry.Tests;
 
public class HostedAgentExtensionTests
{
    [Fact]
    public void AsHostedAgent_InRunMode_AddsHttpEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        // In run mode, the resource should have an HTTP endpoint annotation
        Assert.True(app.Resource.TryGetEndpoints(out var endpoints));
        Assert.Contains(endpoints, e => e.Name == "http");
    }
 
    [Fact]
    public void AsHostedAgent_InRunMode_PreservesExistingHttpEndpointTargetPort()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .WithHttpEndpoint(targetPort: 5000)
            .AsHostedAgent(HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        Assert.True(app.Resource.TryGetEndpoints(out var endpoints));
        var httpEndpoints = endpoints.Where(e => e.Name == "http").ToList();
        Assert.Single(httpEndpoints);
        Assert.Equal(5000, httpEndpoints[0].TargetPort);
        Assert.True(httpEndpoints[0].IsProxied);
    }
 
    [Fact]
    public void AsHostedAgent_InRunMode_DoesNotHardCodePort()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        Assert.True(app.Resource.TryGetEndpoints(out var endpoints));
        var httpEndpoint = endpoints.Single(e => e.Name == "http");
        Assert.Null(httpEndpoint.Port);
    }
 
    [Fact]
    public void AsHostedAgent_InRunMode_ConfiguresSendMessageCommand()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        var resource = builder.Resources.Single(r => r.Name == "agent");
        var command = Assert.Single(resource.Annotations.OfType<ResourceCommandAnnotation>());
        Assert.Equal("Send Message", command.DisplayName);
        Assert.Equal("send-message", command.Name);
        Assert.Equal("ChatSparkle", command.IconName);
        Assert.Equal(IconVariant.Regular, command.IconVariant);
        Assert.True(command.IsHighlighted);
        var argument = Assert.Single(command.Arguments);
        Assert.Equal("message", argument.Name);
        Assert.Equal(InputType.Text, argument.InputType);
        Assert.Equal("Message", argument.Label);
        Assert.True(argument.Required);
        Assert.NotNull(command.ValidateArguments);
    }
 
    [Fact]
    public async Task AsHostedAgent_InRunMode_SendMessageCommandRejectsWhitespaceMessage()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(HostedAgentProtocol.Responses, "2.0.0");
 
        using var app = builder.Build();
 
        var resource = builder.Resources.Single(r => r.Name == "agent");
        var command = Assert.Single(resource.Annotations.OfType<ResourceCommandAnnotation>());
        Assert.NotNull(command.ValidateArguments);
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "   "
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource, command.Name, arguments);
 
        Assert.False(result.Success);
        Assert.Equal("Command argument validation failed.", result.Message);
    }
 
    [Fact]
    public async Task AsHostedAgent_InRunMode_WithInvocationsProtocol_ConfiguresEndpointAndCommand()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Invocations, "1.0.0");
 
        using var app = builder.Build();
 
        var resource = builder.Resources.Single(r => r.Name == "agent");
        var command = Assert.Single(resource.Annotations.OfType<ResourceCommandAnnotation>());
        Assert.Equal("send-message", command.Name);
 
        var urlsCallback = Assert.Single(resource.Annotations.OfType<ResourceUrlsCallbackAnnotation>());
        var url = new ResourceUrlAnnotation
        {
            Url = "http://localhost:1234",
            Endpoint = ((IResourceWithEndpoints)resource).GetEndpoint("http")
        };
        var urls = new List<ResourceUrlAnnotation> { url };
        var context = new ResourceUrlsCallbackContext(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            resource,
            urls);
 
        await urlsCallback.Callback(context);
 
        Assert.Equal("Invocations Endpoint", url.DisplayText);
        Assert.Equal("http://localhost:1234/invocations", url.Url);
    }
 
    [Fact]
    public async Task AsHostedAgent_InRunMode_WithInvocationsProtocol_SendsJsonMessageRequestBody()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, "agent response", "text/plain");
        builder.Services.AddHttpClient(string.Empty)
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var agent = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Invocations, "1.0.0");
        var endpoint = Assert.Single(agent.Resource.Annotations.OfType<EndpointAnnotation>());
        endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 1234);
 
        using var app = builder.Build();
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "hello from dashboard"
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(agent.Resource, "send-message", arguments);
 
        Assert.True(result.Success);
        Assert.Equal("application/json", fakeHandler.RequestContentType);
        Assert.Equal("hello from dashboard", JsonNode.Parse(fakeHandler.RequestContent!)?["message"]?.GetValue<string>());
    }
 
    [Fact]
    public void AsHostedAgent_WithWhitespaceProtocolVersion_Throws()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var ex = Assert.Throws<ArgumentException>(() =>
            builder.AddPythonApp("agent", "./app.py", "main:app")
                .AsHostedAgent(HostedAgentProtocol.Responses, "   "));
 
        Assert.Equal("protocolVersion", ex.ParamName);
    }
 
    [Fact]
    public void AsHostedAgent_InPublishMode_DoesNotValidateRegion()
    {
        using var builder = TestDistributedApplicationBuilder.Create(
            DistributedApplicationOperation.Publish);
 
        builder.Configuration["Azure:Location"] = "invalidregion";
 
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        Assert.NotNull(app);
    }
 
    [Fact]
    public void AsHostedAgent_InPublishMode_AcceptsValidRegion()
    {
        using var builder = TestDistributedApplicationBuilder.Create(
            DistributedApplicationOperation.Publish);
 
        builder.Configuration["Azure:Location"] = "eastus";
 
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        Assert.NotNull(app);
    }
 
    [Fact]
    public void AsHostedAgent_NoRegionConfig_DoesNotThrow()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        Assert.NotNull(app);
    }
 
    [Fact]
    public void AsHostedAgent_InPublishMode_CreatesHostedAgentResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        var hostedAgent = builder.Resources.OfType<AzureHostedAgentResource>().SingleOrDefault();
        Assert.NotNull(hostedAgent);
        Assert.Equal("agent-ha", hostedAgent.Name);
    }
 
    [Fact]
    public void AsHostedAgent_WithProjectOnly_UsesDefaultResponsesProtocol()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project);
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
        Assert.Equal("2.0.0", protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgent_WithProjectAndConfigure_UsesDefaultResponsesProtocolAndAppliesConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, options =>
            {
                options.Description = "configured agent";
            });
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        Assert.Equal("configured agent", configuration.Description);
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
        Assert.Equal("2.0.0", protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgent_LegacyParameterlessOverload_UsesDefaultResponsesProtocol()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent();
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
        Assert.Equal("2.0.0", protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgent_LegacyConfigureOnlyOverload_UsesDefaultResponsesProtocolAndAppliesConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(options =>
            {
                options.Description = "configured agent";
            });
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        Assert.Equal("configured agent", configuration.Description);
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
        Assert.Equal("2.0.0", protocol.Version);
    }
 
    [Fact]
    public async Task AsHostedAgent_InPublishMode_AddsProjectReferenceToDeploymentTarget()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddProject<Project>("agent", launchProfileName: null)
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
 
        Assert.True(hostedAgent.Target.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relationships));
        Assert.Contains(relationships, r =>
            r.Type == "Reference" &&
            ReferenceEquals(r.Resource, project.Resource));
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            hostedAgent.Target, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
 
        Assert.Contains(envVars, kvp =>
            kvp.Key == "ConnectionStrings__my-project" &&
            kvp.Value == "{my-project.connectionString}");
        Assert.Contains(envVars, kvp =>
            kvp.Key == "MY_PROJECT_CONNECTIONSTRING" &&
            kvp.Value == "Endpoint={my-project.outputs.endpoint}");
        Assert.DoesNotContain(hostedAgent.Annotations.OfType<ResourceRelationshipAnnotation>(), r =>
            r.Type == "Reference" &&
            ReferenceEquals(r.Resource, project.Resource));
    }
 
    [Fact]
    public void AsHostedAgent_InPublishMode_AddsDefaultHttpEndpointWhenMissing()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddProject<Project>("agent", launchProfileName: null)
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var endpoint = Assert.Single(hostedAgent.Target.Annotations.OfType<EndpointAnnotation>());
 
        Assert.Equal("http", endpoint.Name);
        Assert.Equal("http", endpoint.UriScheme);
        Assert.Null(endpoint.TargetPort);
    }
 
    [Fact]
    public async Task AsHostedAgent_InPublishMode_IgnoresProjectEndpointTargetPortEnvironment()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddProject<Project>("agent", launchProfileName: null)
            .WithHttpEndpoint(targetPort: 9000, env: "DEFAULT_AD_PORT")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        using var app = builder.Build();
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var endpoint = Assert.Single(hostedAgent.Target.Annotations.OfType<EndpointAnnotation>());
        SetFoundryProjectOutputs(project.Resource);
 
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Equal(9000, endpoint.TargetPort);
        Assert.DoesNotContain("ASPNETCORE_URLS", envVars.Keys);
        Assert.DoesNotContain("HTTP_PORTS", envVars.Keys);
        Assert.DoesNotContain("HTTPS_PORTS", envVars.Keys);
        Assert.DoesNotContain("DEFAULT_AD_PORT", envVars.Keys);
    }
 
    [Fact]
    public void AsHostedAgent_InPublishMode_DotnetProjectKeepsSdkPublishingTarget()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var agent = builder.AddDotnetProject("agent", "agent.csproj", options => options.ExcludeLaunchProfile = true)
            .WithHttpEndpoint(targetPort: 9000, env: "DEFAULT_AD_PORT")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        Assert.Same(agent.Resource, hostedAgent.Target);
        Assert.True(agent.Resource.SupportsDotnetProgramPublishing());
        Assert.DoesNotContain(builder.Resources.OfType<ContainerResource>(), resource => resource.Name == agent.Resource.Name);
        Assert.DoesNotContain(agent.Resource.Annotations, annotation => annotation is DockerfileBuildAnnotation);
        Assert.Contains(agent.Resource.Annotations, annotation => annotation is EndpointEnvironmentInjectionFilterAnnotation);
    }
 
    [Fact]
    public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var options = new HostedAgentOptions
        {
            Description = "test description",
            Cpu = 1m,
            Memory = 2m,
            Metadata = { ["scenario"] = "unit-test" },
            EnvironmentVariables = { ["MY_VAR"] = "my-value" }
        };
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgentWithProtocolForExport(project, HostedAgentProtocol.Invocations, "1.0.0", options);
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
 
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        Assert.Equal("test description", configuration.Description);
        Assert.Equal(1m, configuration.Cpu);
        Assert.Equal(2m, configuration.Memory);
        Assert.Equal("unit-test", configuration.Metadata["scenario"]);
        Assert.Equal("my-value", configuration.EnvironmentVariables["MY_VAR"]);
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Invocations, protocol.Protocol);
        Assert.Equal("1.0.0", protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgentForExport_WithOptions_UsesDefaultResponsesProtocolAndAppliesOptions()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var options = new HostedAgentOptions
        {
            Description = "test description",
            Cpu = 1m,
            Memory = 2m,
            Metadata = { ["scenario"] = "unit-test" },
            EnvironmentVariables = { ["MY_VAR"] = "my-value" }
        };
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgentForExport(project, options);
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
 
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        Assert.Equal("test description", configuration.Description);
        Assert.Equal(1m, configuration.Cpu);
        Assert.Equal(2m, configuration.Memory);
        Assert.Equal("unit-test", configuration.Metadata["scenario"]);
        Assert.Equal("my-value", configuration.EnvironmentVariables["MY_VAR"]);
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
        Assert.Equal(AzureHostedAgentResource.DefaultResponsesProtocolVersion, protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgent_WithConfigureCallback_EnsuresSelectedProtocolRemainsInConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Invocations, "1.0.0", configuration =>
            {
                configuration.ProtocolVersions.Clear();
            });
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Invocations, protocol.Protocol);
        Assert.Equal("1.0.0", protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgent_WithConfigureCallback_DoesNotDuplicateSelectedProtocol()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Invocations, "1.0.0", configuration =>
            {
                configuration.ProtocolVersions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.0.0"));
            });
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure!(configuration);
 
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Invocations, protocol.Protocol);
        Assert.Equal("1.0.0", protocol.Version);
    }
 
    [Fact]
    public async Task GetResolvedEnvironmentVariables_DoesNotForwardFoundryReservedTargetEnvironmentVariables()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var agent = builder.AddExecutable("agent", "python", ".")
            .WithEnvironment("PORT", "8000")
            .WithEnvironment("AGENT_NAME", "agent")
            .WithEnvironment("FOUNDRY_MODE", "hosted")
            .WithEnvironment("MY_VAR", "my-value");
 
        using var app = builder.Build();
        var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource);
 
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.DoesNotContain("PORT", envVars.Keys);
        Assert.DoesNotContain("AGENT_NAME", envVars.Keys);
        Assert.DoesNotContain("FOUNDRY_MODE", envVars.Keys);
        Assert.Equal("my-value", envVars["MY_VAR"]);
    }
 
    [Fact]
    public async Task GetResolvedEnvironmentVariables_EvaluatesHostedAgentTarget()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        IResource? evaluatedResource = null;
        var agent = builder.AddExecutable("agent", "python", ".")
            .WithEnvironment(context =>
            {
                evaluatedResource = context.Resource;
                context.EnvironmentVariables["RESOURCE_NAME"] = context.Resource.Name;
            });
 
        using var app = builder.Build();
        var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource);
 
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Same(hostedAgent.Target, evaluatedResource);
        Assert.Equal(agent.Resource.Name, envVars["RESOURCE_NAME"]);
    }
 
    [Fact]
    public async Task GetResolvedEnvironmentVariables_ProjectsGeneratedConnectionStringAliases()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var connection = builder.AddConnectionString("my-db", ReferenceExpression.Create($"Host=example"));
        var agent = builder.AddExecutable("agent", "python", ".")
            .WithReference(connection)
            .WithEnvironment("ConnectionStrings__my-db", "Host=override")
            .WithEnvironment("custom-name", "custom-value");
 
        using var app = builder.Build();
        var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource);
 
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Collection(
            envVars.OrderBy(static entry => entry.Key, StringComparer.Ordinal),
            entry =>
            {
                Assert.Equal("ConnectionStrings__my_db", entry.Key);
                Assert.Equal("Host=override", entry.Value);
            },
            entry =>
            {
                Assert.Equal("custom-name", entry.Key);
                Assert.Equal("custom-value", entry.Value);
            });
 
        var configuration = new HostedAgentConfiguration("test-image")
        {
            EnvironmentVariables = envVars
        };
        var exception = Assert.Throws<DistributedApplicationException>(
            () => configuration.ToProjectsAgentVersionCreationOptions(agent.Resource.Name));
 
        Assert.Equal(
            "Foundry hosted agent for target resource 'agent' contains environment variable names that are not supported by Foundry Hosted Agents. Environment variable names must contain only ASCII letters, digits, or underscores. Invalid name(s): 'custom-name'",
            exception.Message);
    }
 
    [Fact]
    public async Task GetResolvedEnvironmentVariables_PreservesStandaloneConnectionStringNames()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var connection = builder.AddConnectionString("my-db", ReferenceExpression.Create($"Host=example"));
        var reference = new ConnectionStringReference(connection.Resource, optional: false);
        var agent = builder.AddExecutable("agent", "python", ".")
            .WithEnvironment("ConnectionStrings__my-db", reference)
            .WithEnvironment("ConnectionStrings__my_db", "Host=manual");
 
        using var app = builder.Build();
        var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource);
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Equal(
            new Dictionary<string, string>
            {
                ["ConnectionStrings__my-db"] = "Host=example",
                ["ConnectionStrings__my_db"] = "Host=manual"
            },
            envVars);
    }
 
    [Fact]
    public async Task GetResolvedEnvironmentVariables_ResolvesSelectedConnectionStringExpression()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var connection = builder.AddConnectionString("my-db", ReferenceExpression.Create($"Host=primary"));
#pragma warning disable ASPIRECONNECTIONSTRINGS001
        var names = ConnectionStringEnvironmentVariableNames.Create(connection.Resource, "my-db-http");
        var reference = new ConnectionStringReference(
            connection.Resource, optional: false, names, "HttpConnectionStringExpression",
            ReferenceExpression.Create($"Host=http"));
        var agent = builder.AddExecutable("agent", "python", ".")
            .WithEnvironment(names.OriginalName, reference)
            .WithEnvironment(names.PortableName, reference);
#pragma warning restore ASPIRECONNECTIONSTRINGS001
 
        using var app = builder.Build();
        var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource);
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Equal(
            new Dictionary<string, string> { ["ConnectionStrings__my_db_http"] = "Host=http" },
            envVars);
    }
 
    [Fact]
    public async Task GetResolvedEnvironmentVariables_PreservesReplacedConnectionStringAliases()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var connection = builder.AddConnectionString("my-db", ReferenceExpression.Create($"Host=example"));
        var agent = builder.AddExecutable("agent", "python", ".")
            .WithReference(connection)
            .WithEnvironment("ConnectionStrings__my-db", "Host=original")
            .WithEnvironment("ConnectionStrings__my_db", "Host=portable");
 
        using var app = builder.Build();
        var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource);
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            hostedAgent,
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Equal(
            new Dictionary<string, string>
            {
                ["ConnectionStrings__my-db"] = "Host=original",
                ["ConnectionStrings__my_db"] = "Host=portable"
            },
            envVars);
    }
 
    [Fact]
    public void GetAgentEndpointProtocols_MapsContainerProtocolsToEndpointProtocols()
    {
        var endpointProtocols = AzureHostedAgentResource.GetAgentEndpointProtocols(
            [
                new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.0.0"),
                new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0"),
                new ProtocolVersionRecord(ProjectsAgentProtocol.ActivityProtocol, "1.0.0"),
                new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.1.0")
            ]);
 
        Assert.Collection(
            endpointProtocols,
            protocol => Assert.Equal(AgentEndpointProtocol.Invocations, protocol),
            protocol => Assert.Equal(AgentEndpointProtocol.Responses, protocol),
            protocol => Assert.Equal(AgentEndpointProtocol.Activity, protocol));
    }
 
    [Fact]
    public void AsHostedAgent_WithNullOptions_StillSetsProtocolConfigureCallback()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgentWithProtocolForExport(project, HostedAgentProtocol.Responses, "2.0.0", options: null);
 
        builder.Build();
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        Assert.NotNull(hostedAgent.Configure);
        var configuration = new HostedAgentConfiguration("test-image");
        hostedAgent.Configure(configuration);
        var protocol = Assert.Single(configuration.ProtocolVersions);
        Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
        Assert.Equal("2.0.0", protocol.Version);
    }
 
    [Fact]
    public void AsHostedAgent_WithNullProject_Throws()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var app = builder.AddPythonApp("agent", "./app.py", "main:app");
 
        Assert.Throws<ArgumentNullException>(() => app.AsHostedAgentForExport(project: null!));
    }
 
    [Fact]
    public void AsHostedAgent_WithoutProject_CreatesDefaultProject()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        var project = builder.Resources.OfType<AzureCognitiveServicesProjectResource>().SingleOrDefault();
        Assert.NotNull(project);
    }
 
    [Fact]
    public void AsHostedAgent_InRunMode_WithProject_AddsProjectDependency()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var app = builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        Assert.Contains(app.Resource.Annotations.OfType<WaitAnnotation>(), w => ReferenceEquals(w.Resource, project.Resource));
    }
 
    [Fact]
    public void AsHostedAgent_InRunMode_WithProject_DoesNotCreateDefaultContainerRegistryResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        builder.Build();
 
        Assert.Null(project.Resource.DefaultContainerRegistry);
        Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr");
    }
 
    [Fact]
    public async Task AsHostedAgent_InRunMode_WithProject_ExecutesBeforeStartHooksWithoutContainerRegistry()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        using var app = builder.Build();
 
        await ExecuteBeforeStartHooksAsync(app, default);
    }
 
    [Fact]
    public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var registry = builder.AddAzureContainerRegistry("global");
        builder.AddFoundry("account")
            .AddProject("my-project");
        var container = builder.AddContainer("redis", "redis:latest");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model));
 
        var registryTargets = container.Resource.Annotations.OfType<RegistryTargetAnnotation>().ToList();
        var registryTarget = Assert.Single(registryTargets);
        Assert.Same(registry.Resource, registryTarget.Registry);
    }
 
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
    private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
 
    private static void SetFoundryProjectOutputs(AzureCognitiveServicesProjectResource project)
    {
        project.Outputs["endpoint"] = "https://account.services.ai.azure.com/api/projects/my-project";
        project.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = "";
        project.ProvisioningTaskCompletionSource?.TrySetResult();
    }
 
    [Fact]
    public void AsHostedAgent_StampsReferenceRoleAssignmentAnnotationOnTarget_WithFoundryUserRole()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var account = Assert.Single(builder.Resources.OfType<FoundryResource>());
 
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates.
        var annotation = Assert.Single(hostedAgent.Target.Annotations.OfType<ReferenceRoleAssignmentAnnotation>());
        Assert.Same(account, annotation.Target);
        Assert.Contains(annotation.Roles, role =>
            string.Equals(role.Id, FoundryResource.FoundryUserRoleDefinitionId, StringComparison.OrdinalIgnoreCase));
#pragma warning restore ASPIREAZURE003
    }
 
    [Fact]
    public void AsHostedAgent_ReferenceRoleAssignmentAnnotation_GrantsOnlyFoundryUserRole()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
 
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
        var account = Assert.Single(builder.Resources.OfType<FoundryResource>());
        Assert.True(account.TryGetLastAnnotation<DefaultRoleAssignmentsAnnotation>(out var defaults));
 
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates.
        var annotation = Assert.Single(hostedAgent.Target.Annotations.OfType<ReferenceRoleAssignmentAnnotation>());
 
        // The implied grant is least-privilege: only "Foundry User" is required to invoke the agent.
        var role = Assert.Single(annotation.Roles);
        Assert.Equal(FoundryResource.FoundryUserRoleDefinitionId, role.Id, ignoreCase: true);
 
        // The account's default data-plane roles must NOT be folded in here. A consumer that references
        // the account directly still receives them via the preparer's normal walk, and a consumer that
        // explicitly suppresses them must keep them suppressed.
        foreach (var defaultRole in defaults.Roles)
        {
            Assert.DoesNotContain(annotation.Roles, r => string.Equals(r.Id, defaultRole.Id, StringComparison.OrdinalIgnoreCase));
        }
#pragma warning restore ASPIREAZURE003
    }
 
    [Fact]
    public void AsHostedAgent_MultipleHostedAgents_EachTargetCarriesItsOwnReferenceRoleAssignment()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var otherProject = builder.AddFoundry("account2")
            .AddProject("other-project");
 
        builder.AddPythonApp("agent", "./app.py", "main:app")
            .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
        builder.AddPythonApp("agent2", "./app.py", "main:app")
            .AsHostedAgent(otherProject, HostedAgentProtocol.Responses, "2.0.0");
 
        var hostedAgents = builder.Resources.OfType<AzureHostedAgentResource>().ToList();
        Assert.Equal(2, hostedAgents.Count);
 
        var account = Assert.Single(builder.Resources.OfType<FoundryResource>(), r => r.Name == "account");
        var account2 = Assert.Single(builder.Resources.OfType<FoundryResource>(), r => r.Name == "account2");
 
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates.
        var targets = hostedAgents
            .Select(a => Assert.Single(a.Target.Annotations.OfType<ReferenceRoleAssignmentAnnotation>()).Target)
            .ToList();
 
        Assert.Contains(account, targets);
        Assert.Contains(account2, targets);
#pragma warning restore ASPIREAZURE003
    }
 
    private sealed class FakeHttpMessageHandler(HttpStatusCode statusCode, string responseBody, string mediaType) : HttpMessageHandler
    {
        public string? RequestContent { get; private set; }
 
        public string? RequestContentType { get; private set; }
 
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            RequestContent = request.Content is not null
                ? await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)
                : null;
            RequestContentType = request.Content?.Headers.ContentType?.MediaType;
 
            return new HttpResponseMessage(statusCode)
            {
                Content = new StringContent(responseBody, Encoding.UTF8, mediaType)
            };
        }
    }
 
    private sealed class Project : IProjectMetadata
    {
        public string ProjectPath => "project";
    }
}