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.
 
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,
            hostedAgent.Target,
            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_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,
            agent.Resource,
            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 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_WithAzureAIUserRole()
    {
        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, AzureHostedAgentResource.AzureAIUserRoleDefinitionId, StringComparison.OrdinalIgnoreCase));
#pragma warning restore ASPIREAZURE003
    }
 
    [Fact]
    public void AsHostedAgent_ReferenceRoleAssignmentAnnotation_GrantsOnlyAzureAIUserRole()
    {
        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 "Azure AI User" is required to invoke the agent.
        var role = Assert.Single(annotation.Roles);
        Assert.Equal(AzureHostedAgentResource.AzureAIUserRoleDefinitionId, 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";
    }
}