diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 62efc40b..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Build and test - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: build-${{ github.ref }} - cancel-in-progress: true - -jobs: - platform: - name: Platform - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - name: Restore - run: dotnet restore Agentstration.slnx -p:NuGetAudit=true -p:NuGetAuditMode=all - - name: Build - run: dotnet build Agentstration.slnx --configuration Release --no-restore - - name: Test - run: dotnet test Agentstration.slnx --configuration Release --no-build - - aep: - name: AEP SDK - runs-on: ubuntu-latest - defaults: - run: - working-directory: aep - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - name: Restore - run: dotnet restore Aep.slnx -p:NuGetAudit=true -p:NuGetAuditMode=all - - name: Build - run: dotnet build Aep.slnx --configuration Release --no-restore - - name: Test - run: dotnet test Aep.slnx --configuration Release --no-build - - container: - name: Container - runs-on: ubuntu-latest - needs: platform - steps: - - uses: actions/checkout@v4 - - name: Build image - run: docker build --file Dockerfile --tag agentstration:ci . diff --git a/src/Agentstration.Web/Hosting/TestingDataDirectoryCleanupService.cs b/src/Agentstration.Web/Hosting/TestingDataDirectoryCleanupService.cs new file mode 100644 index 00000000..ed5773af --- /dev/null +++ b/src/Agentstration.Web/Hosting/TestingDataDirectoryCleanupService.cs @@ -0,0 +1,44 @@ +using Microsoft.Data.Sqlite; + +namespace Agentstration.Web.Hosting; + +internal sealed class TestingDataDirectoryCleanupService(string directory, bool deleteOnShutdown) : IHostedService +{ + internal string DirectoryPath { get; } = directory; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + ClearPoolsInDirectory(DirectoryPath); + if (!deleteOnShutdown) return; + + for (var attempt = 0; ; attempt++) + { + try + { + if (Directory.Exists(DirectoryPath)) Directory.Delete(DirectoryPath, recursive: true); + return; + } + catch (IOException) when (attempt < 4) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + } + catch (UnauthorizedAccessException) when (attempt < 4) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + } + } + } + + private static void ClearPoolsInDirectory(string directory) + { + if (!Directory.Exists(directory)) return; + + foreach (var databasePath in Directory.EnumerateFiles(directory, "*.db", SearchOption.AllDirectories)) + { + using var connection = new SqliteConnection($"Data Source={databasePath}"); + SqliteConnection.ClearPool(connection); + } + } +} diff --git a/src/Agentstration.Web/Program.cs b/src/Agentstration.Web/Program.cs index 80b4df1f..1acb9004 100644 --- a/src/Agentstration.Web/Program.cs +++ b/src/Agentstration.Web/Program.cs @@ -39,18 +39,21 @@ var toolExecutionCapture = builder.Configuration.GetSection("Agentstration:ToolExecution").Get() ?? new(); toolExecutionCapture.Validate(); builder.Services.AddSingleton(toolExecutionCapture); -var dataDirectory = builder.Configuration["Data:Directory"] ?? Path.Combine(builder.Environment.ContentRootPath, ".agentstration"); +var isTesting = builder.Environment.IsEnvironment("Testing"); +var configuredTestingDataDirectory = builder.Configuration["Data:TestingDirectory"]; +var ownsTestingDataDirectory = isTesting && string.IsNullOrWhiteSpace(configuredTestingDataDirectory); +var dataDirectory = isTesting + ? string.IsNullOrWhiteSpace(configuredTestingDataDirectory) + ? Path.Combine(Path.GetTempPath(), $"agentstration-web-tests-{Guid.NewGuid():N}") + : configuredTestingDataDirectory + : builder.Configuration["Data:Directory"] ?? Path.Combine(builder.Environment.ContentRootPath, ".agentstration"); Directory.CreateDirectory(dataDirectory); var identityConnectionString = builder.Configuration.GetConnectionString("Identity") - ?? (builder.Environment.IsEnvironment("Testing") - ? $"Data Source={Path.Combine(Path.GetTempPath(), $"agentstration-identity-tests-{Guid.NewGuid():N}.db")}" - : $"Data Source={Path.Combine(dataDirectory, "identity.db")}"); + ?? $"Data Source={Path.Combine(dataDirectory, "identity.db")}"; var dataProtectionKeysPath = configuredAuthentication.DataProtectionKeysPath; if (string.IsNullOrWhiteSpace(dataProtectionKeysPath)) { - dataProtectionKeysPath = builder.Environment.IsEnvironment("Testing") - ? Path.Combine(Path.GetTempPath(), $"agentstration-data-protection-tests-{Guid.NewGuid():N}") - : Path.Combine(dataDirectory, "data-protection-keys"); + dataProtectionKeysPath = Path.Combine(dataDirectory, "data-protection-keys"); } var aiProvider = builder.Configuration["AI:Provider"] ?? "Managed"; var useManagedProfileResolver = string.Equals(aiProvider, "Managed", StringComparison.OrdinalIgnoreCase); @@ -58,26 +61,31 @@ var aiEndpoint = builder.Configuration["AI:Endpoint"] ?? defaultAiEndpoint; if (!Uri.TryCreate(aiEndpoint.EndsWith('/') ? aiEndpoint : aiEndpoint + '/', UriKind.Absolute, out var parsedAiEndpoint)) throw new InvalidOperationException("AI:Endpoint must be an absolute URL."); var aiOptions = new AiProviderOptions(aiProvider, parsedAiEndpoint, builder.Configuration["AI:Model"] ?? "phi4-mini", builder.Configuration["AI:ApiKey"]); -var controlPlanePath = builder.Environment.IsEnvironment("Testing") - ? Path.Combine(Path.GetTempPath(), $"agentstration-tests-{Guid.NewGuid():N}.db") +var controlPlanePath = isTesting + ? Path.Combine(dataDirectory, "control-plane.db") : builder.Configuration["Data:ControlPlanePath"] ?? Path.Combine(builder.Environment.ContentRootPath, ".agentstration", "control-plane.db"); var controlPlaneDirectory = Path.GetDirectoryName(controlPlanePath); if (!string.IsNullOrWhiteSpace(controlPlaneDirectory)) Directory.CreateDirectory(controlPlaneDirectory); -var workPlanePath = builder.Environment.IsEnvironment("Testing") - ? Path.Combine(Path.GetTempPath(), $"agentstration-work-tests-{Guid.NewGuid():N}.db") +var workPlanePath = isTesting + ? Path.Combine(dataDirectory, "work-plane.db") : builder.Configuration["Data:WorkPlanePath"] ?? Path.Combine(builder.Environment.ContentRootPath, ".agentstration", "work-plane.db"); var workPlaneDirectory = Path.GetDirectoryName(workPlanePath); if (!string.IsNullOrWhiteSpace(workPlaneDirectory)) Directory.CreateDirectory(workPlaneDirectory); -var flowPath = builder.Environment.IsEnvironment("Testing") - ? Path.Combine(Path.GetTempPath(), $"agentstration-flow-tests-{Guid.NewGuid():N}.db") +var flowPath = isTesting + ? Path.Combine(dataDirectory, "flow-plane.db") : builder.Configuration["Data:FlowPath"] ?? Path.Combine(builder.Environment.ContentRootPath, ".agentstration", "flow-plane.db"); var flowDirectory = Path.GetDirectoryName(flowPath); if (!string.IsNullOrWhiteSpace(flowDirectory)) Directory.CreateDirectory(flowDirectory); -var runtimePath = builder.Environment.IsEnvironment("Testing") - ? Path.Combine(Path.GetTempPath(), $"agentstration-runtime-tests-{Guid.NewGuid():N}.db") +var runtimePath = isTesting + ? Path.Combine(dataDirectory, "runtime-plane.db") : builder.Configuration["Data:RuntimePath"] ?? Path.Combine(builder.Environment.ContentRootPath, ".agentstration", "runtime-plane.db"); var runtimeDirectory = Path.GetDirectoryName(runtimePath); if (!string.IsNullOrWhiteSpace(runtimeDirectory)) Directory.CreateDirectory(runtimeDirectory); +if (isTesting) +{ + builder.Services.AddSingleton(new TestingDataDirectoryCleanupService(dataDirectory, ownsTestingDataDirectory)); + builder.Services.AddSingleton(provider => provider.GetRequiredService()); +} builder.Services.AddAgentstration(dataDirectory, aiOptions, $"Data Source={controlPlanePath}", $"Data Source={workPlanePath}", $"Data Source={flowPath}", $"Data Source={runtimePath}"); builder.Services.AddAgentstrationModelProviders( builder.Configuration, diff --git a/tests/Agentstration.Management.Tests/AssemblyInfo.cs b/tests/Agentstration.Management.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..903504ed --- /dev/null +++ b/tests/Agentstration.Management.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)] diff --git a/tests/Agentstration.Management.Tests/ControlPlaneStoreHardeningTests.cs b/tests/Agentstration.Management.Tests/ControlPlaneStoreHardeningTests.cs index 7b811998..d556b7c7 100644 --- a/tests/Agentstration.Management.Tests/ControlPlaneStoreHardeningTests.cs +++ b/tests/Agentstration.Management.Tests/ControlPlaneStoreHardeningTests.cs @@ -3,7 +3,6 @@ using Agentstration.Management.Storage.Sqlite; using Agentstration.Resources; using Agentstration.Runtime.Abstractions; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Management.Tests; @@ -261,7 +260,7 @@ public static async Task CreateAsync(ICurrentRequestContext? conte public async ValueTask DisposeAsync() { await Provider.DisposeAsync(); - SqliteConnection.ClearAllPools(); + SqliteTestCleanup.ClearPoolsInDirectory(directory); Directory.Delete(directory, true); } diff --git a/tests/Agentstration.Management.Tests/DeclarativeBootstrapTests.cs b/tests/Agentstration.Management.Tests/DeclarativeBootstrapTests.cs index 51c04974..6ead3a23 100644 --- a/tests/Agentstration.Management.Tests/DeclarativeBootstrapTests.cs +++ b/tests/Agentstration.Management.Tests/DeclarativeBootstrapTests.cs @@ -342,6 +342,7 @@ private static WebApplicationFactory Factory(string path, string? passw new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); + builder.UseSetting("Data:TestingDirectory", Path.Combine(path, ".test-data")); builder.UseSetting("Agentstration:Authentication:Mode", "Local"); builder.UseSetting("Agentstration:Bootstrap:Path", Directory.GetParent(path)!.FullName); builder.UseSetting("Agentstration:Bootstrap:InitialBootstrapEnabled", "true"); @@ -433,6 +434,10 @@ public TemporaryDirectory() } public string Path { get; } - public void Dispose() => Directory.Delete(Path, recursive: true); + public void Dispose() + { + SqliteTestCleanup.ClearPoolsInDirectory(Path); + Directory.Delete(Path, recursive: true); + } } } diff --git a/tests/Agentstration.Management.Tests/IdentityFoundationTests.cs b/tests/Agentstration.Management.Tests/IdentityFoundationTests.cs index 8754d8fc..aa8f6d4e 100644 --- a/tests/Agentstration.Management.Tests/IdentityFoundationTests.cs +++ b/tests/Agentstration.Management.Tests/IdentityFoundationTests.cs @@ -403,7 +403,7 @@ public async Task ExecuteSqlAsync(string sql) public async ValueTask DisposeAsync() { await Services.DisposeAsync(); - SqliteConnection.ClearAllPools(); + SqliteTestCleanup.ClearPoolsInDirectory(directory); Directory.Delete(directory, recursive: true); } } diff --git a/tests/Agentstration.Management.Tests/LocalIdentityPersistenceTests.cs b/tests/Agentstration.Management.Tests/LocalIdentityPersistenceTests.cs index 395160c2..1b54c5d2 100644 --- a/tests/Agentstration.Management.Tests/LocalIdentityPersistenceTests.cs +++ b/tests/Agentstration.Management.Tests/LocalIdentityPersistenceTests.cs @@ -1,7 +1,6 @@ using Agentstration.Security.AspNetCoreIdentity; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -145,7 +144,7 @@ private static string TemporaryDirectory() private static void DeleteTemporaryDirectory(string path) { - SqliteConnection.ClearAllPools(); + SqliteTestCleanup.ClearPoolsInDirectory(path); if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } } diff --git a/tests/Agentstration.Management.Tests/PackCompositionTests.cs b/tests/Agentstration.Management.Tests/PackCompositionTests.cs index b6d05db3..ec3217e1 100644 --- a/tests/Agentstration.Management.Tests/PackCompositionTests.cs +++ b/tests/Agentstration.Management.Tests/PackCompositionTests.cs @@ -5,7 +5,6 @@ using Agentstration.Management.Core; using Agentstration.Management.Storage.Sqlite; using Agentstration.Resources; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Management.Tests; @@ -71,7 +70,7 @@ public async Task CreateProjectStoresImmutableWorkspaceSnapshot() } finally { - SqliteConnection.ClearAllPools(); + SqliteTestCleanup.ClearPoolsInDirectory(directory); if (Directory.Exists(directory)) Directory.Delete(directory, true); } } diff --git a/tests/Agentstration.Management.Tests/PackTests.cs b/tests/Agentstration.Management.Tests/PackTests.cs index 9b24f047..c47216d3 100644 --- a/tests/Agentstration.Management.Tests/PackTests.cs +++ b/tests/Agentstration.Management.Tests/PackTests.cs @@ -15,7 +15,6 @@ using Agentstration.Runtime.Contracts; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Management.Tests; @@ -880,7 +879,7 @@ public static async Task CreateAsync() } public async ValueTask DisposeAsync() { - await provider.DisposeAsync(); SqliteConnection.ClearAllPools(); if (Directory.Exists(directory)) Directory.Delete(directory, true); + await provider.DisposeAsync(); SqliteTestCleanup.ClearPoolsInDirectory(directory); if (Directory.Exists(directory)) Directory.Delete(directory, true); } } } diff --git a/tests/Agentstration.Management.Tests/SecurityAuditPersistenceTests.cs b/tests/Agentstration.Management.Tests/SecurityAuditPersistenceTests.cs index 25ac7ee7..dbe691d3 100644 --- a/tests/Agentstration.Management.Tests/SecurityAuditPersistenceTests.cs +++ b/tests/Agentstration.Management.Tests/SecurityAuditPersistenceTests.cs @@ -1,6 +1,5 @@ using Agentstration.Management.Abstractions; using Agentstration.Management.Storage.Sqlite; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Management.Tests; @@ -45,7 +44,7 @@ public async Task AuditEventsSurviveControlPlaneProviderRestart() } finally { - SqliteConnection.ClearAllPools(); + SqliteTestCleanup.ClearPoolsInDirectory(directory); if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } } diff --git a/tests/Agentstration.Management.Tests/SqliteTestCleanup.cs b/tests/Agentstration.Management.Tests/SqliteTestCleanup.cs new file mode 100644 index 00000000..28503c80 --- /dev/null +++ b/tests/Agentstration.Management.Tests/SqliteTestCleanup.cs @@ -0,0 +1,22 @@ +using Microsoft.Data.Sqlite; + +namespace Agentstration.Management.Tests; + +internal static class SqliteTestCleanup +{ + public static void ClearPoolsInDirectory(string directory) + { + if (!Directory.Exists(directory)) return; + + foreach (var databasePath in Directory.EnumerateFiles(directory, "*.db", SearchOption.AllDirectories)) + { + ClearPool(databasePath); + } + } + + public static void ClearPool(string databasePath) + { + using var connection = new SqliteConnection($"Data Source={databasePath}"); + SqliteConnection.ClearPool(connection); + } +} diff --git a/tests/Agentstration.Management.Tests/TriggerTests.cs b/tests/Agentstration.Management.Tests/TriggerTests.cs index 9f2f46f5..7a87d223 100644 --- a/tests/Agentstration.Management.Tests/TriggerTests.cs +++ b/tests/Agentstration.Management.Tests/TriggerTests.cs @@ -9,7 +9,6 @@ using Agentstration.Management.Core; using Agentstration.Management.Storage.Sqlite; using Agentstration.Resources; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Management.Tests; @@ -114,7 +113,7 @@ public async Task OccurrenceStoreIsIdempotentWorkspaceScopedAndSurvivesRestartAs Assert.IsEmpty(await restarted.Store.ListAsync(Guid.NewGuid(), trigger, 10, CancellationToken.None)); } } - finally { SqliteConnection.ClearAllPools(); File.Delete(database); } + finally { SqliteTestCleanup.ClearPool(database); File.Delete(database); } } [TestMethod] @@ -299,7 +298,7 @@ public static async Task CreateAsync() public async ValueTask DisposeAsync() { await Services.DisposeAsync(); - SqliteConnection.ClearAllPools(); + SqliteTestCleanup.ClearPool(Database); File.Delete(Database); } } diff --git a/tests/Agentstration.Web.Tests/LegacyVerticalRemovalTests.cs b/tests/Agentstration.Web.Tests/LegacyVerticalRemovalTests.cs index e186fda9..b8c84c0f 100644 --- a/tests/Agentstration.Web.Tests/LegacyVerticalRemovalTests.cs +++ b/tests/Agentstration.Web.Tests/LegacyVerticalRemovalTests.cs @@ -90,7 +90,7 @@ public async Task StartupDoesNotCreateLegacyDataJson() new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory ?? Path.Combine(Path.GetTempPath(), $"agentstration-web-tests-{Guid.NewGuid():N}")); + if (dataDirectory is not null) builder.UseSetting("Data:TestingDirectory", dataDirectory); builder.UseSetting("Logging:LogLevel:Default", "Warning"); }); } diff --git a/tests/Agentstration.Web.Tests/McpToolCatalogTests.cs b/tests/Agentstration.Web.Tests/McpToolCatalogTests.cs index f4321990..b57befcf 100644 --- a/tests/Agentstration.Web.Tests/McpToolCatalogTests.cs +++ b/tests/Agentstration.Web.Tests/McpToolCatalogTests.cs @@ -1,10 +1,10 @@ -extern alias UtilitiesExtension; - -using System.Net.Http; -using System.Text.Json; -using Agentstration.Management.Abstractions; -using Agentstration.Runtime.Abstractions; -using Agentstration.Runtime.Core; +extern alias UtilitiesExtension; + +using System.Net.Http; +using System.Text.Json; +using Agentstration.Management.Abstractions; +using Agentstration.Runtime.Abstractions; +using Agentstration.Runtime.Core; using Agentstration.Tools.Mcp; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -13,13 +13,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; - -namespace Agentstration.Web.Tests; - -[TestClass] -public sealed class McpToolCatalogTests -{ - [TestMethod] + +namespace Agentstration.Web.Tests; + +[TestClass] +public sealed class McpToolCatalogTests +{ + [TestMethod] public void ServerMcpEndpointRemainsAvailableWithoutLegacyPlatformTools() { using var host = new WebApplicationFactory().WithWebHostBuilder(builder => builder.UseEnvironment("Testing")); @@ -47,40 +47,40 @@ public void ServerMcpEndpointRemainsAvailableWithoutLegacyPlatformTools() Assert.IsFalse(publishedToolNames.Any(name => legacyTools.Contains(name, StringComparer.OrdinalIgnoreCase))); Assert.IsNull(typeof(global::Program).Assembly.GetType("Agentstration.Web.PlatformMcpTools")); } - - [TestMethod] - public async Task GovernanceBlocksDisabledProviderToolAndUnavailableTool() - { - await using var host = new WebApplicationFactory().WithWebHostBuilder(builder => builder.UseEnvironment("Testing")); - var adapter = Adapter(host); - var provider = Provider(); - var tool = Tool(provider.Metadata.Name); - - await AssertCodeAsync("tool_provider_disabled", provider with { Definition = provider.Definition with { Enabled = false } }, tool); - await AssertCodeAsync("tool_disabled", provider, tool with { Definition = tool.Definition with { Enabled = false } }); - await AssertCodeAsync("tool_unavailable", provider, tool with { Definition = tool.Definition with { Discovery = tool.Definition.Discovery! with { Available = false } } }); - - async Task AssertCodeAsync(string code, ToolProviderResource currentProvider, ToolResource currentTool) - { - var store = new FakeStore(currentProvider, currentTool); - var catalog = new McpToolCatalog(store, adapter); - var error = await Assert.ThrowsAsync(async () => await catalog.ResolveAsync([currentTool.Metadata.Name])); - Assert.AreEqual(code, error.Code); - var invocationError = await Assert.ThrowsAsync(async () => - await new ToolExecutionPipeline(new McpToolInvoker(store, adapter)).ExecuteAsync(new ToolExecutionContext - { - ToolCallId = "governance-call", - InvocationId = "governance-invocation", - ToolId = currentTool.Metadata.Name, - ToolName = currentTool.Definition.ExternalId ?? currentTool.Metadata.Name, - ToolProviderId = currentProvider.Metadata.Name, - ExternalToolId = currentTool.Definition.ExternalId - })); - Assert.AreEqual(code, invocationError.Code); - } - } - - [TestMethod] + + [TestMethod] + public async Task GovernanceBlocksDisabledProviderToolAndUnavailableTool() + { + await using var host = new WebApplicationFactory().WithWebHostBuilder(builder => builder.UseEnvironment("Testing")); + var adapter = Adapter(host); + var provider = Provider(); + var tool = Tool(provider.Metadata.Name); + + await AssertCodeAsync("tool_provider_disabled", provider with { Definition = provider.Definition with { Enabled = false } }, tool); + await AssertCodeAsync("tool_disabled", provider, tool with { Definition = tool.Definition with { Enabled = false } }); + await AssertCodeAsync("tool_unavailable", provider, tool with { Definition = tool.Definition with { Discovery = tool.Definition.Discovery! with { Available = false } } }); + + async Task AssertCodeAsync(string code, ToolProviderResource currentProvider, ToolResource currentTool) + { + var store = new FakeStore(currentProvider, currentTool); + var catalog = new McpToolCatalog(store, adapter); + var error = await Assert.ThrowsAsync(async () => await catalog.ResolveAsync([currentTool.Metadata.Name])); + Assert.AreEqual(code, error.Code); + var invocationError = await Assert.ThrowsAsync(async () => + await new ToolExecutionPipeline(new McpToolInvoker(store, adapter)).ExecuteAsync(new ToolExecutionContext + { + ToolCallId = "governance-call", + InvocationId = "governance-invocation", + ToolId = currentTool.Metadata.Name, + ToolName = currentTool.Definition.ExternalId ?? currentTool.Metadata.Name, + ToolProviderId = currentProvider.Metadata.Name, + ExternalToolId = currentTool.Definition.ExternalId + })); + Assert.AreEqual(code, invocationError.Code); + } + } + + [TestMethod] public async Task ApprovalGovernanceIsRetainedAsProviderNeutralMetadata() { await using var host = new WebApplicationFactory(); @@ -98,110 +98,110 @@ public async Task ApprovalGovernanceIsRetainedAsProviderNeutralMetadata() new TestHttpClientFactory(host.Server.CreateHandler()), NullLoggerFactory.Instance); var catalog = new McpToolCatalog(new FakeStore(provider, tool), adapter); - - var runtime = (await catalog.ResolveAsync([tool.Metadata.Name])).Single(); - - Assert.IsTrue(runtime.RequiresApproval); - Assert.IsFalse(runtime is AITool); - } - - [TestMethod] - public async Task AepContributionInvokesMcpThroughTheSameExecutionPipeline() - { - await using var host = new WebApplicationFactory(); - var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - ["Agentstration:Extensions:Agentstration.Extensions.Utilities:Endpoint"] = "http://extension/" - }).Build(); - var adapter = new ToolProviderAdapter( - new ConfigurationAepExtensionEndpointResolver(configuration), - new ConfigurationToolProviderEnvironmentResolver(configuration), - new TestHttpClientFactory(host.Server.CreateHandler()), - NullLoggerFactory.Instance); - var provider = Provider() with - { - Metadata = new ResourceMetadata { Name = "utilities" }, - Definition = new ToolProviderProperties - { - DisplayName = "Utilities AEP", - ProviderType = ToolProviderType.Aep, - Aep = new AepToolProviderConfiguration { ExtensionId = "Agentstration.Extensions.Utilities" } - } - }; - var tool = Tool(provider.Metadata.Name) with - { - Metadata = new ResourceMetadata { Name = "utilities.hash.compute" }, - Definition = Tool(provider.Metadata.Name).Definition with { ExternalId = "hash.compute" } - }; - var store = new FakeStore(provider, tool); - var descriptor = (await new McpToolCatalog(store, adapter).ResolveAsync([tool.Metadata.Name])).Single(); - var pipeline = new ToolExecutionPipeline(new McpToolInvoker(store, adapter)); - var context = Context(descriptor) with - { - Arguments = JsonSerializer.SerializeToElement(new { text = "agentstration" }) - }; - - var result = await pipeline.ExecuteAsync(context); - - Assert.AreEqual("utilities", descriptor.ProviderId); - Assert.AreEqual("hash.compute", descriptor.ExternalId); - Assert.IsNotNull(result); - StringAssert.Contains(result.Value.GetRawText(), UtilitiesExtension::Agentstration.Extensions.Utilities.UtilityTools.ComputeHash("agentstration")); - } - - private static ToolExecutionContext Context(IAgentTool tool) => new() - { - ToolCallId = "call-1", - InvocationId = "invocation-1", - ToolId = tool.Id, - ToolName = tool.Name, - ToolProviderId = tool.ProviderId, - ExternalToolId = tool.ExternalId - }; - - private static ToolProviderAdapter Adapter(WebApplicationFactory host) - { - var configuration = new ConfigurationBuilder().Build(); - return new ToolProviderAdapter(new ConfigurationAepExtensionEndpointResolver(configuration), new ConfigurationToolProviderEnvironmentResolver(configuration), new TestHttpClientFactory(host.Server.CreateHandler()), NullLoggerFactory.Instance); - } - - private static ToolProviderResource Provider() => new() - { - ApiVersion = ManagementApiVersions.CoreV1, - Kind = ResourceKinds.ToolProvider, - Metadata = new ResourceMetadata { Name = "local" }, - Definition = new ToolProviderProperties { DisplayName = "Local MCP", ProviderType = ToolProviderType.Mcp, Mcp = new McpToolProviderConfiguration { Transport = McpToolProviderTransport.StreamableHttp, Endpoint = new Uri("http://localhost/mcp") } } - }; - - private static ToolResource Tool(string providerId) => new() - { - ApiVersion = ManagementApiVersions.CoreV1, - Kind = ResourceKinds.Tool, + + var runtime = (await catalog.ResolveAsync([tool.Metadata.Name])).Single(); + + Assert.IsTrue(runtime.RequiresApproval); + Assert.IsFalse(runtime is AITool); + } + + [TestMethod] + public async Task AepContributionInvokesMcpThroughTheSameExecutionPipeline() + { + await using var host = new WebApplicationFactory(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Agentstration:Extensions:Agentstration.Extensions.Utilities:Endpoint"] = "http://extension/" + }).Build(); + var adapter = new ToolProviderAdapter( + new ConfigurationAepExtensionEndpointResolver(configuration), + new ConfigurationToolProviderEnvironmentResolver(configuration), + new TestHttpClientFactory(host.Server.CreateHandler()), + NullLoggerFactory.Instance); + var provider = Provider() with + { + Metadata = new ResourceMetadata { Name = "utilities" }, + Definition = new ToolProviderProperties + { + DisplayName = "Utilities AEP", + ProviderType = ToolProviderType.Aep, + Aep = new AepToolProviderConfiguration { ExtensionId = "Agentstration.Extensions.Utilities" } + } + }; + var tool = Tool(provider.Metadata.Name) with + { + Metadata = new ResourceMetadata { Name = "utilities.hash.compute" }, + Definition = Tool(provider.Metadata.Name).Definition with { ExternalId = "hash.compute" } + }; + var store = new FakeStore(provider, tool); + var descriptor = (await new McpToolCatalog(store, adapter).ResolveAsync([tool.Metadata.Name])).Single(); + var pipeline = new ToolExecutionPipeline(new McpToolInvoker(store, adapter)); + var context = Context(descriptor) with + { + Arguments = JsonSerializer.SerializeToElement(new { text = "agentstration" }) + }; + + var result = await pipeline.ExecuteAsync(context); + + Assert.AreEqual("utilities", descriptor.ProviderId); + Assert.AreEqual("hash.compute", descriptor.ExternalId); + Assert.IsNotNull(result); + StringAssert.Contains(result.Value.GetRawText(), UtilitiesExtension::Agentstration.Extensions.Utilities.UtilityTools.ComputeHash("agentstration")); + } + + private static ToolExecutionContext Context(IAgentTool tool) => new() + { + ToolCallId = "call-1", + InvocationId = "invocation-1", + ToolId = tool.Id, + ToolName = tool.Name, + ToolProviderId = tool.ProviderId, + ExternalToolId = tool.ExternalId + }; + + private static ToolProviderAdapter Adapter(WebApplicationFactory host) + { + var configuration = new ConfigurationBuilder().Build(); + return new ToolProviderAdapter(new ConfigurationAepExtensionEndpointResolver(configuration), new ConfigurationToolProviderEnvironmentResolver(configuration), new TestHttpClientFactory(host.Server.CreateHandler()), NullLoggerFactory.Instance); + } + + private static ToolProviderResource Provider() => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolProvider, + Metadata = new ResourceMetadata { Name = "local" }, + Definition = new ToolProviderProperties { DisplayName = "Local MCP", ProviderType = ToolProviderType.Mcp, Mcp = new McpToolProviderConfiguration { Transport = McpToolProviderTransport.StreamableHttp, Endpoint = new Uri("http://localhost/mcp") } } + }; + + private static ToolResource Tool(string providerId) => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.Tool, Metadata = new ResourceMetadata { Name = "local.sample_tool" }, - Definition = new ToolResourceProperties - { - DisplayName = "List workspaces", - Provider = new ResourceReference(providerId), + Definition = new ToolResourceProperties + { + DisplayName = "List workspaces", + Provider = new ResourceReference(providerId), ExternalId = "sample_tool", - Enabled = true, - Discovery = new ToolDiscoveryState { Available = true, FirstSeenAt = DateTimeOffset.UnixEpoch, LastSeenAt = DateTimeOffset.UnixEpoch }, - Schema = new ToolSchema { Input = JsonSerializer.SerializeToElement(new { type = "object" }) } - } - }; - - private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, false) { BaseAddress = new Uri("http://localhost/") }; - } - - private sealed class FakeStore(params Resource[] resources) : IControlPlaneStore - { - private readonly Dictionary values = resources.ToDictionary(value => new ResourceKey(value.Kind, value.Metadata.Name)); - public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task?> GetAsync(ResourceKey key, CancellationToken cancellationToken) where T : Resource => Task.FromResult(values.TryGetValue(key, out var value) && value is T typed ? new StoredResource(typed, "test", DateTimeOffset.UnixEpoch) : null); - public Task>> ListAsync(string kind, int skip, int take, CancellationToken cancellationToken) where T : Resource => throw new NotSupportedException(); - public Task> PutAsync(T resource, string? ifMatch, bool ifNoneMatch, CancellationToken cancellationToken) where T : Resource => throw new NotSupportedException(); - public Task> CreateImmutableAsync(T resource, CancellationToken cancellationToken) where T : Resource => throw new NotSupportedException(); - public Task DeleteAsync(ResourceKey key, string? ifMatch, CancellationToken cancellationToken) => throw new NotSupportedException(); - } -} + Enabled = true, + Discovery = new ToolDiscoveryState { Available = true, FirstSeenAt = DateTimeOffset.UnixEpoch, LastSeenAt = DateTimeOffset.UnixEpoch }, + Schema = new ToolSchema { Input = JsonSerializer.SerializeToElement(new { type = "object" }) } + } + }; + + private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, false) { BaseAddress = new Uri("http://localhost/") }; + } + + private sealed class FakeStore(params Resource[] resources) : IControlPlaneStore + { + private readonly Dictionary values = resources.ToDictionary(value => new ResourceKey(value.Kind, value.Metadata.Name)); + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task?> GetAsync(ResourceKey key, CancellationToken cancellationToken) where T : Resource => Task.FromResult(values.TryGetValue(key, out var value) && value is T typed ? new StoredResource(typed, "test", DateTimeOffset.UnixEpoch) : null); + public Task>> ListAsync(string kind, int skip, int take, CancellationToken cancellationToken) where T : Resource => throw new NotSupportedException(); + public Task> PutAsync(T resource, string? ifMatch, bool ifNoneMatch, CancellationToken cancellationToken) where T : Resource => throw new NotSupportedException(); + public Task> CreateImmutableAsync(T resource, CancellationToken cancellationToken) where T : Resource => throw new NotSupportedException(); + public Task DeleteAsync(ResourceKey key, string? ifMatch, CancellationToken cancellationToken) => throw new NotSupportedException(); + } +} diff --git a/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs b/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs index 84e0a6b7..0bb00a28 100644 --- a/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs +++ b/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs @@ -1,12 +1,42 @@ using System.Net; +using Agentstration.Web.Hosting; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Web.Tests; [TestClass] public sealed class QuartzHostLifecycleTests { + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public async Task MissingOrWhitespaceTestingDirectoryUsesAndDeletesOwnedDirectory(string? configuredDirectory) + { + string directory; + await using (var factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + if (configuredDirectory is not null) builder.UseSetting("Data:TestingDirectory", configuredDirectory); + builder.UseSetting("Logging:LogLevel:Default", "Warning"); + })) + { + using var client = factory.CreateClient(); + Assert.AreEqual(HttpStatusCode.OK, (await client.GetAsync("/health")).StatusCode); + directory = factory.Services.GetRequiredService().DirectoryPath; + Assert.IsTrue(Directory.Exists(directory)); + Assert.IsTrue(File.Exists(Path.Combine(directory, "control-plane.db"))); + Assert.IsTrue(File.Exists(Path.Combine(directory, "work-plane.db"))); + Assert.IsTrue(File.Exists(Path.Combine(directory, "flow-plane.db"))); + Assert.IsTrue(File.Exists(Path.Combine(directory, "runtime-plane.db"))); + Assert.IsTrue(File.Exists(Path.Combine(directory, "identity.db"))); + } + + Assert.IsFalse(Directory.Exists(directory)); + } + [TestMethod] public async Task SchedulerDatabaseIsReleasedAfterEveryHostShutdown() { @@ -36,7 +66,7 @@ public async Task SchedulerDatabaseIsReleasedAfterEveryHostShutdown() new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory); + builder.UseSetting("Data:TestingDirectory", dataDirectory); builder.UseSetting("Logging:LogLevel:Default", "Warning"); }); } diff --git a/tests/Agentstration.Work.Api.Tests/WorkplaceApiTests.cs b/tests/Agentstration.Work.Api.Tests/WorkplaceApiTests.cs index 3e61a99c..844c8fe5 100644 --- a/tests/Agentstration.Work.Api.Tests/WorkplaceApiTests.cs +++ b/tests/Agentstration.Work.Api.Tests/WorkplaceApiTests.cs @@ -36,7 +36,7 @@ public async Task AgentBindingPublishesPinnedDirectAgentFlowAndDraftDoesNotAffec await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory); + builder.UseSetting("Data:TestingDirectory", dataDirectory); }); using var client = factory.CreateClient(); @@ -206,7 +206,7 @@ public async Task TaskFlowRunEndpointEnforcesTenantWorkspaceAndPrincipalScope() await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory); + builder.UseSetting("Data:TestingDirectory", dataDirectory); }); using var client = factory.CreateClient(); var current = await factory.Services.GetRequiredService().EnsureInitializedAsync(default); @@ -301,7 +301,7 @@ public async Task ImmediateResponseConversationCanContinueWithoutCreatingATask() await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory); + builder.UseSetting("Data:TestingDirectory", dataDirectory); }); using var client = factory.CreateClient(); using var submittedResponse = await client.PostAsJsonAsync( @@ -342,7 +342,7 @@ public async Task CompletedTaskCanContinueWithANewCorrelatedFlowRunAndVersionedO await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory); + builder.UseSetting("Data:TestingDirectory", dataDirectory); }); using var client = factory.CreateClient(); using var submittedResponse = await client.PostAsJsonAsync( @@ -440,7 +440,7 @@ public async Task PendingActionUsesSingleUseTokenAndDoesNotInventDeliverables() await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Testing"); - builder.UseSetting("Data:Directory", dataDirectory); + builder.UseSetting("Data:TestingDirectory", dataDirectory); }); using var client = factory.CreateClient(); var taskCreated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);