elsa-core/test/unit/Elsa.AI.Host.UnitTests/AIToolRegistryTests.cs
Sipke Schoorstra 3936258146
Implement Weaver AI Copilot core (#7523)
* Implement Weaver AI Copilot core

* Address Greptile review feedback

* Address Greptile persistence feedback

* Address Greptile orchestration feedback

* Address Greptile tool isolation feedback

* Wire chat audit events

* Stream chat events over SSE

* Use server identity for AI endpoints

* Validate AI proposal persistence

* Isolate AI audit failures

* Enforce AI tool lookup scope

* Support AI tool result continuations

* Handle AI chat reconnects safely

* Tighten AI context and reconnect behavior

* Guard AI conversation and persistence setup

* Persist AI tool-loop progress

* Tighten AI tool registry and reconnect cleanup

* Handle AI preparation failures cleanly

* Order AI tool messages after assistant turns

* Initialize AI provider sessions

* Align AI context capabilities

* Prevent completed AI reconnect replay

* Enforce AI conversation ownership

* Default AI proposal creation time

* Persist AI session and retention defaults

* Allow AI context provider overrides

* Scope AI tool results per turn

* Apply AI provider configuration

* Scope AI proposal reads

* Avoid duplicate AI tool continuations

* Resolve AI tool registry scopes

* Tighten AI reconnect cleanup

* Honor default AI proposal tools

* Pass AI provider session to turns

* Close AI observability gaps

* Fix AI capabilities options alias

* Harden AI orchestration lifetimes

* Track actual AI reconnect conversation

* Address AI audit and context review findings

* Fix AI reconnect and persistence capabilities

* Handle AI session startup failures

* Tighten AI orchestration review gaps

* Warn on placeholder AI context

* Filter disabled AI provider tools

* Add durable AI conversation persistence

* Fix AI orchestrator persistence lifetime

* Handle failed AI reconnect edge cases

* Harden AI reconnect failure handling

* Address AI reconnect and cleanup review gaps

* Tighten AI audit and cleanup persistence

* Keep expired AI cleanup best effort

* Tighten AI tool lookup and cleanup fallback

* Handle AI provider and tenant edge cases

* Tighten AI proposal and agent authorization

* Address AI tool scope cleanup review

* Close remaining AI greptile findings

* Harden AI stores and tool defaults

* Harden AI conversation persistence edge cases

* Cover AI proposal and tool visibility guards

* Fix AI capabilities and audit batch resilience

* Fix AI conversation truncation for unicode

* Resolve remaining AI persistence review nits

* Wire AI conversation persistence option

* Address AI audit and proposal style review

* Fix AI stream truncation surrogate handling

* Address AI context and cleanup review

* Preserve AI titles and tenant tool defaults

* Guard AI conversation user ownership

* Align in-memory AI conversation ownership

* Fix expired AI conversation cleanup tracking

* Harden AI proposal persistence retry

* Tighten AI proposal reads and cleanup SQL

* Harden AI reconnect and provider defaults

* Optimize AI tool listing and message trimming

* Preserve AI conversation timestamps

* Address final AI persistence review nits

* Normalize AI acronym casing

* Address Copilot AI review comments

* Normalize default tenant handling for AI stores

* Harden AI registry and message truncation

* Make AI tool filtering explicit

* Align AI contracts with implementation

* Align remaining AI review contracts

* address greptile ai persistence feedback

* Address Copilot AI persistence feedback

* Address Copilot AI host feedback

* Order persisted AI conversation messages

* Address Copilot chat and cleanup feedback

* Release unused AI reconnect reservations

* Address Copilot AI review feedback

* Address Copilot tool and conversation feedback

* Address Copilot governance feedback

* Address Copilot tool test feedback

* Address AI review follow-ups

* Address Copilot AI follow-ups

* Clean up AI persistence tests

* Address IAITool disposal review

* Address AI integration review follow-ups

* Address AI chat persistence review

* Address AI registry and truncation review

* Enable read-only AI tools by default

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 11:35:44 +02:00

553 lines
20 KiB
C#

using Elsa.AI.Abstractions.Contracts;
using Elsa.AI.Abstractions.Models;
using Elsa.AI.Host.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.AI.Host.UnitTests;
public class AIToolRegistryTests
{
[Fact(DisplayName = "Tool registry excludes host and cross-tenant denied tools from tenant queries")]
public async Task ToolRegistryExcludesHostAndCrossTenantDeniedToolsFromTenantQueries()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition { Name = "tenant", DisplayName = "Tenant", TenantBehavior = AITenantBehavior.TenantScoped }),
new TestTool(new AIToolDefinition { Name = "host", DisplayName = "Host", TenantBehavior = AITenantBehavior.HostScoped }),
new TestTool(new AIToolDefinition { Name = "cross", DisplayName = "Cross", TenantBehavior = AITenantBehavior.CrossTenantDenied })
]);
var tools = await registry.ListAsync(new AIToolQuery
{
TenantId = "tenant-1",
ActorId = "user-1"
});
var tool = Assert.Single(tools);
Assert.Equal("tenant", tool.Name);
}
[Fact(DisplayName = "Tool registry treats empty tenant ID as tenant context")]
public async Task ToolRegistryTreatsEmptyTenantIdAsTenantContext()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition { Name = "tenant", DisplayName = "Tenant", TenantBehavior = AITenantBehavior.TenantScoped }),
new TestTool(new AIToolDefinition { Name = "host", DisplayName = "Host", TenantBehavior = AITenantBehavior.HostScoped }),
new TestTool(new AIToolDefinition
{
Name = "default-tenant-cross",
DisplayName = "Default tenant cross",
TenantBehavior = AITenantBehavior.CrossTenantDenied,
TenantIds = [""]
})
]);
var tools = await registry.ListAsync(new AIToolQuery
{
TenantId = "",
ActorId = "user-1"
});
Assert.Collection(
tools.OrderBy(x => x.Name),
tool => Assert.Equal("default-tenant-cross", tool.Name),
tool => Assert.Equal("tenant", tool.Name));
}
[Fact(DisplayName = "Tool registry allows cross-tenant denied tools for explicit tenant allowlists")]
public async Task ToolRegistryAllowsCrossTenantDeniedToolsForExplicitTenantAllowlists()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition
{
Name = "tenant-cross",
DisplayName = "Tenant cross",
TenantBehavior = AITenantBehavior.CrossTenantDenied,
TenantIds = ["tenant-1"]
})
]);
var tools = await registry.ListAsync(new AIToolQuery
{
TenantId = "tenant-1",
ActorId = "user-1"
});
var tool = Assert.Single(tools);
Assert.Equal("tenant-cross", tool.Name);
}
[Fact(DisplayName = "Tool registry excludes cross-tenant denied tools from host queries")]
public async Task ToolRegistryExcludesCrossTenantDeniedToolsFromHostQueries()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition { Name = "host", DisplayName = "Host", TenantBehavior = AITenantBehavior.HostScoped }),
new TestTool(new AIToolDefinition { Name = "cross", DisplayName = "Cross", TenantBehavior = AITenantBehavior.CrossTenantDenied })
]);
var tools = await registry.ListAsync(new AIToolQuery
{
ActorId = "user-1"
});
var tool = Assert.Single(tools);
Assert.Equal("host", tool.Name);
}
[Fact(DisplayName = "Tool registry exposes default tools without tenant context")]
public async Task ToolRegistryExposesDefaultToolsWithoutTenantContext()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition { Name = "default", DisplayName = "Default" })
]);
var tools = await registry.ListAsync(new AIToolQuery { ActorId = "user-1" });
var tool = Assert.Single(tools);
Assert.Equal("default", tool.Name);
}
[Fact(DisplayName = "Tool registry exposes default tools to tenant queries")]
public async Task ToolRegistryExposesDefaultToolsToTenantQueries()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition { Name = "default", DisplayName = "Default" })
]);
var tools = await registry.ListAsync(new AIToolQuery { TenantId = "tenant-1", ActorId = "user-1" });
var tool = Assert.Single(tools);
Assert.Equal("default", tool.Name);
}
[Fact(DisplayName = "Tool registry excludes incomplete tool definition names")]
public async Task ToolRegistryExcludesIncompleteToolDefinitionNames()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition { DisplayName = "Incomplete" }),
new TestTool(new AIToolDefinition { Name = "valid", DisplayName = "Valid" })
]);
var tools = await registry.ListAsync(new AIToolQuery { ActorId = "user-1" });
var tool = Assert.Single(tools);
Assert.Equal("valid", tool.Name);
}
[Fact(DisplayName = "Tool registry uses cached concrete type lookup after listing tools")]
public async Task ToolRegistryUsesCachedConcreteTypeLookupAfterListingTools()
{
CountingTool.ConstructionCount = 0;
OtherCountingTool.ConstructionCount = 0;
var services = new ServiceCollection();
services.AddTransient<IAITool, CountingTool>();
services.AddTransient<IAITool, OtherCountingTool>();
var registry = CreateRegistry(services);
await registry.ListAsync(new AIToolQuery { ActorId = "user-1" });
CountingTool.ConstructionCount = 0;
OtherCountingTool.ConstructionCount = 0;
using var tool = await registry.FindAsync("counting", new AIToolQuery { ActorId = "user-1" });
Assert.NotNull(tool);
Assert.Equal(1, CountingTool.ConstructionCount);
Assert.Equal(0, OtherCountingTool.ConstructionCount);
}
[Fact(DisplayName = "Tool registry disposes cached concrete tool instances")]
public async Task ToolRegistryDisposesCachedConcreteToolInstances()
{
DisposableCachedTool.ConstructionCount = 0;
DisposableCachedTool.DisposeCount = 0;
var services = new ServiceCollection();
services.AddTransient<IAITool, DisposableCachedTool>();
var registry = CreateRegistry(services);
await registry.ListAsync(new AIToolQuery { ActorId = "user-1" });
DisposableCachedTool.ConstructionCount = 0;
DisposableCachedTool.DisposeCount = 0;
var tool = await registry.FindAsync("disposable-cached", new AIToolQuery { ActorId = "user-1" });
tool?.Dispose();
Assert.NotNull(tool);
Assert.Equal(1, DisposableCachedTool.ConstructionCount);
Assert.Equal(1, DisposableCachedTool.DisposeCount);
}
[Fact(DisplayName = "Tool registry find applies tenant and actor filters")]
public async Task ToolRegistryFindAppliesTenantAndActorFilters()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition
{
Name = "restricted",
DisplayName = "Restricted",
EnabledByDefault = true,
TenantBehavior = AITenantBehavior.TenantScoped,
TenantIds = ["tenant-1"],
ActorIds = ["user-1"]
})
]);
using var allowed = await registry.FindAsync("restricted", new AIToolQuery { TenantId = "tenant-1", ActorId = "user-1" });
var denied = await registry.FindAsync("restricted", new AIToolQuery { TenantId = "tenant-2", ActorId = "user-1" });
Assert.NotNull(allowed);
Assert.Null(denied);
}
[Fact(DisplayName = "Tool registry enforces tool permission requirements")]
public async Task ToolRegistryEnforcesToolPermissionRequirements()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition
{
Name = "restricted",
DisplayName = "Restricted",
EnabledByDefault = true,
TenantBehavior = AITenantBehavior.TenantScoped,
Permissions = ["workflows:write"]
})
]);
var denied = await registry.FindAsync("restricted", new AIToolQuery { TenantId = "tenant-1", ActorId = "user-1" });
using var allowed = await registry.FindAsync("restricted", new AIToolQuery
{
TenantId = "tenant-1",
ActorId = "user-1",
UserPermissions = ["Workflows:Write"]
});
Assert.Null(denied);
Assert.NotNull(allowed);
}
[Fact(DisplayName = "Tool enablement requires explicit administrative enablement")]
public void ToolEnablementRequiresExplicitAdministrativeEnablement()
{
var enablement = new AIToolEnablementService();
var definition = new AIToolDefinition
{
Name = "admin",
DisplayName = "Admin",
Mutability = AIToolMutability.Administrative,
EnabledByDefault = true
};
Assert.False(enablement.IsEnabled(definition));
enablement.Enable("admin");
Assert.False(enablement.IsEnabled(definition));
enablement.EnableAdministrative("admin");
Assert.True(enablement.IsEnabled(definition));
}
[Fact(DisplayName = "Tool registry filters agent-scoped tools by agent")]
public async Task ToolRegistryFiltersAgentScopedToolsByAgent()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition
{
Name = "agent-only",
DisplayName = "Agent only",
EnabledByDefault = true,
TenantBehavior = AITenantBehavior.TenantScoped,
AgentScopes = ["workflow-author"]
}),
new TestTool(new AIToolDefinition
{
Name = "agent-permission",
DisplayName = "Agent permission",
EnabledByDefault = true,
TenantBehavior = AITenantBehavior.TenantScoped,
AgentScopes = ["workflow-author"],
Permissions = ["workflows:write"]
})
]);
var deniedWithoutPermission = await registry.ListAsync(new AIToolQuery
{
Agent = "workflow-author",
TenantId = "tenant-1",
ActorId = "user-1"
});
var deniedByAgent = await registry.ListAsync(new AIToolQuery
{
Agent = "workflow-editor",
TenantId = "tenant-1",
ActorId = "user-1",
UserPermissions = ["workflows:write"]
});
var allowedWithPermission = await registry.ListAsync(new AIToolQuery
{
Agent = "workflow-author",
TenantId = "tenant-1",
ActorId = "user-1",
UserPermissions = ["workflows:write"]
});
var tool = Assert.Single(deniedWithoutPermission);
Assert.Equal("agent-only", tool.Name);
Assert.Empty(deniedByAgent);
Assert.Collection(
allowedWithPermission.OrderBy(x => x.Name),
agentOnly => Assert.Equal("agent-only", agentOnly.Name),
agentPermission => Assert.Equal("agent-permission", agentPermission.Name));
}
[Fact(DisplayName = "Tool registry honors tenant and actor allowlists")]
public async Task ToolRegistryHonorsTenantAndActorAllowlists()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition
{
Name = "matching",
DisplayName = "Matching",
TenantBehavior = AITenantBehavior.TenantScoped,
TenantIds = ["tenant-1"],
ActorIds = ["user-1"]
}),
new TestTool(new AIToolDefinition
{
Name = "wrong-tenant",
DisplayName = "Wrong tenant",
TenantBehavior = AITenantBehavior.TenantScoped,
TenantIds = ["tenant-2"]
}),
new TestTool(new AIToolDefinition
{
Name = "wrong-actor",
DisplayName = "Wrong actor",
ActorIds = ["user-2"]
})
]);
var tools = await registry.ListAsync(new AIToolQuery
{
TenantId = "tenant-1",
ActorId = "user-1"
});
var tool = Assert.Single(tools);
Assert.Equal("matching", tool.Name);
}
[Fact(DisplayName = "Tool registry combines tenant actor agent and permission filters")]
public async Task ToolRegistryCombinesTenantActorAgentAndPermissionFilters()
{
var registry = CreateRegistry(
[
new TestTool(new AIToolDefinition
{
Name = "matching",
DisplayName = "Matching",
TenantBehavior = AITenantBehavior.TenantScoped,
TenantIds = ["tenant-1"],
ActorIds = ["user-1"],
AgentScopes = ["workflow-author"],
Permissions = ["workflows:write"]
}),
new TestTool(new AIToolDefinition
{
Name = "wrong-agent",
DisplayName = "Wrong agent",
TenantBehavior = AITenantBehavior.TenantScoped,
TenantIds = ["tenant-1"],
ActorIds = ["user-1"],
AgentScopes = ["workflow-editor"],
Permissions = ["workflows:write"]
}),
new TestTool(new AIToolDefinition
{
Name = "wrong-permission",
DisplayName = "Wrong permission",
TenantBehavior = AITenantBehavior.TenantScoped,
TenantIds = ["tenant-1"],
ActorIds = ["user-1"],
AgentScopes = ["workflow-author"],
Permissions = ["deployments:write"]
})
]);
var tools = await registry.ListAsync(new AIToolQuery
{
TenantId = "tenant-1",
ActorId = "user-1",
Agent = "workflow-author",
UserPermissions = ["workflows:write"]
});
var tool = Assert.Single(tools);
Assert.Equal("matching", tool.Name);
}
[Fact(DisplayName = "Tool registry skips tools with throwing definitions")]
public async Task ToolRegistrySkipsToolsWithThrowingDefinitions()
{
var tracker = new ScopeDisposalTracker();
var services = new ServiceCollection();
services.AddSingleton(tracker);
services.AddScoped<ScopedDependency>();
services.AddScoped<IAITool, ThrowingDefinitionTool>();
services.AddScoped<IAITool>(_ => new TestTool(new AIToolDefinition { Name = "healthy", DisplayName = "Healthy" }));
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
var registry = new AIToolRegistry(provider.GetRequiredService<IServiceScopeFactory>(), new AIToolEnablementService());
var missing = await registry.FindAsync("throwing", new AIToolQuery());
var tools = await registry.ListAsync(new AIToolQuery());
Assert.Null(missing);
var tool = Assert.Single(tools);
Assert.Equal("healthy", tool.Name);
Assert.Equal(2, tracker.DisposeCount);
}
private static AIToolRegistry CreateRegistry(IReadOnlyCollection<IAITool> tools)
{
var services = new ServiceCollection();
foreach (var tool in tools)
services.AddScoped<IAITool>(_ => tool);
return CreateRegistry(services);
}
private static AIToolRegistry CreateRegistry(IServiceCollection services)
{
var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
return new AIToolRegistry(provider.GetRequiredService<IServiceScopeFactory>(), new AIToolEnablementService());
}
private class TestTool(AIToolDefinition definition) : IAITool
{
public AIToolDefinition Definition { get; } = definition;
public ValueTask<AIToolResult> ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new AIToolResult());
public void Dispose()
{
}
}
private class CountingTool : IAITool
{
public static int ConstructionCount { get; set; }
public CountingTool()
{
ConstructionCount++;
}
public AIToolDefinition Definition { get; } = new()
{
Name = "counting",
DisplayName = "Counting",
EnabledByDefault = true
};
public ValueTask<AIToolResult> ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new AIToolResult());
public void Dispose()
{
}
}
private class OtherCountingTool : IAITool
{
public static int ConstructionCount { get; set; }
public OtherCountingTool()
{
ConstructionCount++;
}
public AIToolDefinition Definition { get; } = new()
{
Name = "other-counting",
DisplayName = "Other counting",
EnabledByDefault = true
};
public ValueTask<AIToolResult> ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new AIToolResult());
public void Dispose()
{
}
}
private class DisposableCachedTool : IAITool
{
public static int ConstructionCount { get; set; }
public static int DisposeCount { get; set; }
public DisposableCachedTool()
{
ConstructionCount++;
}
public AIToolDefinition Definition { get; } = new()
{
Name = "disposable-cached",
DisplayName = "Disposable cached",
EnabledByDefault = true
};
public ValueTask<AIToolResult> ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new AIToolResult());
public void Dispose()
{
DisposeCount++;
}
}
private class ThrowingDefinitionTool(ScopedDependency dependency) : IAITool
{
private readonly ScopedDependency _dependency = dependency;
public AIToolDefinition Definition
{
get
{
_ = _dependency;
throw new InvalidOperationException("Definition unavailable.");
}
}
public ValueTask<AIToolResult> ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new AIToolResult());
public void Dispose()
{
}
}
private class ScopedDependency(ScopeDisposalTracker tracker) : IDisposable
{
public void Dispose()
{
tracker.DisposeCount++;
}
}
private class ScopeDisposalTracker
{
public int DisposeCount { get; set; }
}
}