diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 4c379cc34..0bd9364b8 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -1,5 +1,6 @@ using Taskdeck.Api.Realtime; using Taskdeck.Application.Services; +using Taskdeck.Domain.Agents; namespace Taskdeck.Api.Extensions; @@ -51,6 +52,15 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddSingleton(); + // Agent tool registry (singleton — populated once at startup, read concurrently) + var toolRegistry = new TaskdeckToolRegistry(); + toolRegistry.RegisterTool(InboxTriageAssistant.GetToolDefinition()); + services.AddSingleton(toolRegistry); + + // Agent policy evaluator and inbox triage assistant + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs b/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs new file mode 100644 index 000000000..852e5e58a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Evaluates agent tool-use requests against the profile's policy configuration +/// and the tool's risk level. Review-first is the default for all risk levels; +/// low-risk auto-apply is OFF unless explicitly opted in via policy. +/// +public sealed class AgentPolicyEvaluator : IAgentPolicyEvaluator +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ITaskdeckToolRegistry _toolRegistry; + private readonly ILogger? _logger; + + public AgentPolicyEvaluator( + IUnitOfWork unitOfWork, + ITaskdeckToolRegistry toolRegistry, + ILogger? logger = null) + { + _unitOfWork = unitOfWork; + _toolRegistry = toolRegistry; + _logger = logger; + } + + public async Task EvaluateToolUseAsync( + Guid agentProfileId, + string toolKey, + IDictionary? context = null, + CancellationToken cancellationToken = default) + { + if (agentProfileId == Guid.Empty) + { + _logger?.LogWarning("Policy evaluation denied: empty agent profile ID"); + return PolicyDecision.Deny("Agent profile ID is required."); + } + + if (string.IsNullOrWhiteSpace(toolKey)) + { + _logger?.LogWarning("Policy evaluation denied: empty tool key"); + return PolicyDecision.Deny("Tool key is required."); + } + + // Look up the tool in the registry + var tool = _toolRegistry.GetTool(toolKey); + if (tool is null) + { + _logger?.LogWarning("Policy evaluation denied: tool '{ToolKey}' not found in registry", toolKey); + return PolicyDecision.Deny($"Tool '{toolKey}' is not registered."); + } + + // Look up the agent profile + var profile = await _unitOfWork.AgentProfiles.GetByIdAsync(agentProfileId, cancellationToken); + if (profile is null) + { + _logger?.LogWarning("Policy evaluation denied: agent profile '{ProfileId}' not found", agentProfileId); + return PolicyDecision.Deny("Agent profile not found."); + } + + if (!profile.IsEnabled) + { + _logger?.LogInformation( + "Policy evaluation denied: agent profile '{ProfileId}' is disabled", agentProfileId); + return PolicyDecision.Deny("Agent profile is disabled."); + } + + // Parse the profile's policy JSON + var policy = ParsePolicy(profile.PolicyJson); + + // Check tool allowlist: if a non-empty allowlist is defined, the tool must be in it + if (policy.AllowedTools.Count > 0 && !policy.AllowedTools.Contains(toolKey, StringComparer.OrdinalIgnoreCase)) + { + _logger?.LogInformation( + "Policy evaluation denied: tool '{ToolKey}' not in allowlist for profile '{ProfileId}'", + toolKey, agentProfileId); + return PolicyDecision.Deny($"Tool '{toolKey}' is not in this agent's allowed tool list."); + } + + // Enforce risk-level constraints + // High risk: always requires review, never auto-apply + if (tool.RiskLevel == ToolRiskLevel.High) + { + _logger?.LogInformation( + "Policy evaluation: tool '{ToolKey}' requires review (high risk) for profile '{ProfileId}'", + toolKey, agentProfileId); + return PolicyDecision.AllowWithReview($"High-risk tool '{tool.DisplayName}' requires review before execution."); + } + + // Medium risk: always requires review + if (tool.RiskLevel == ToolRiskLevel.Medium) + { + _logger?.LogInformation( + "Policy evaluation: tool '{ToolKey}' requires review (medium risk) for profile '{ProfileId}'", + toolKey, agentProfileId); + return PolicyDecision.AllowWithReview($"Medium-risk tool '{tool.DisplayName}' requires review before execution."); + } + + // Low risk: review-first by default; direct apply only if explicitly enabled + if (policy.AutoApplyLowRisk) + { + _logger?.LogInformation( + "Policy evaluation: tool '{ToolKey}' allowed direct (low risk, auto-apply enabled) for profile '{ProfileId}'", + toolKey, agentProfileId); + return PolicyDecision.AllowDirect($"Low-risk tool '{tool.DisplayName}' auto-applied per policy."); + } + + _logger?.LogInformation( + "Policy evaluation: tool '{ToolKey}' requires review (low risk, auto-apply off) for profile '{ProfileId}'", + toolKey, agentProfileId); + return PolicyDecision.AllowWithReview($"Low-risk tool '{tool.DisplayName}' requires review (auto-apply is off)."); + } + + /// + /// Parse the profile's PolicyJson into a structured policy configuration. + /// Returns safe defaults if the JSON is missing or malformed. + /// + internal static AgentPolicyConfig ParsePolicy(string? policyJson) + { + if (string.IsNullOrWhiteSpace(policyJson) || policyJson == "{}") + return AgentPolicyConfig.Default; + + try + { + using var doc = JsonDocument.Parse(policyJson); + var root = doc.RootElement; + + var allowedTools = new List(); + if (root.TryGetProperty("allowedTools", out var toolsElement) && toolsElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in toolsElement.EnumerateArray()) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + allowedTools.Add(value); + } + } + + var autoApplyLowRisk = false; + if (root.TryGetProperty("autoApplyLowRisk", out var autoApplyElement) + && autoApplyElement.ValueKind == JsonValueKind.True) + { + autoApplyLowRisk = true; + } + + return new AgentPolicyConfig(allowedTools, autoApplyLowRisk); + } + catch (JsonException) + { + return AgentPolicyConfig.Default; + } + } +} + +/// +/// Parsed representation of the agent profile's policy configuration. +/// +internal sealed record AgentPolicyConfig( + IReadOnlyList AllowedTools, + bool AutoApplyLowRisk) +{ + public static AgentPolicyConfig Default { get; } = new(Array.Empty(), false); +} diff --git a/backend/src/Taskdeck.Application/Services/IAgentPolicyEvaluator.cs b/backend/src/Taskdeck.Application/Services/IAgentPolicyEvaluator.cs new file mode 100644 index 000000000..319b20ff3 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAgentPolicyEvaluator.cs @@ -0,0 +1,25 @@ +using Taskdeck.Domain.Agents; + +namespace Taskdeck.Application.Services; + +/// +/// Evaluates whether a given agent profile is allowed to use a specific tool, +/// and under what constraints (review-first, direct apply, or deny). +/// +public interface IAgentPolicyEvaluator +{ + /// + /// Evaluate whether the agent profile identified by + /// may invoke the tool identified by in the given context. + /// + /// The agent profile requesting tool use. + /// The tool registry key to evaluate. + /// Optional contextual metadata (e.g. board ID, item count). + /// Cancellation token. + /// A describing whether the action is allowed. + Task EvaluateToolUseAsync( + Guid agentProfileId, + string toolKey, + IDictionary? context = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/InboxTriageAssistant.cs b/backend/src/Taskdeck.Application/Services/InboxTriageAssistant.cs new file mode 100644 index 000000000..c73597d7b --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/InboxTriageAssistant.cs @@ -0,0 +1,197 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Bounded agent template that triages inbox items into proposals. +/// Never directly mutates board state — all changes are routed through +/// the proposal system and policy evaluator. +/// +public sealed class InboxTriageAssistant +{ + /// Tool key for the inbox triage tool registered in the tool registry. + public const string ToolKey = "inbox.triage"; + + /// Maximum number of inbox items to gather in a single triage run. + private const int MaxInboxItemsPerRun = 20; + + private readonly IUnitOfWork _unitOfWork; + private readonly IAgentPolicyEvaluator _policyEvaluator; + private readonly IAutomationProposalService _proposalService; + private readonly ILogger? _logger; + + public InboxTriageAssistant( + IUnitOfWork unitOfWork, + IAgentPolicyEvaluator policyEvaluator, + IAutomationProposalService proposalService, + ILogger? logger = null) + { + _unitOfWork = unitOfWork; + _policyEvaluator = policyEvaluator; + _proposalService = proposalService; + _logger = logger; + } + + /// + /// Run the inbox triage template for a given agent profile and board. + /// Gathers pending inbox items, evaluates policy, and creates a proposal + /// for triage actions. Returns a failure result if policy denies the action + /// or if no actionable items are found. + /// + public async Task> RunTriageAsync( + Guid agentProfileId, + Guid userId, + Guid boardId, + CancellationToken cancellationToken = default) + { + if (agentProfileId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "Agent profile ID is required."); + + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID is required."); + + if (boardId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required."); + + // Evaluate policy before proceeding + var policyDecision = await _policyEvaluator.EvaluateToolUseAsync( + agentProfileId, + ToolKey, + new Dictionary { ["boardId"] = boardId.ToString() }, + cancellationToken); + + if (!policyDecision.Allowed) + { + _logger?.LogInformation( + "Inbox triage denied by policy for profile '{ProfileId}': {Reason}", + agentProfileId, policyDecision.Reason); + return Result.Failure(ErrorCodes.Forbidden, policyDecision.Reason); + } + + // Gather inbox context: recent pending items for this user + var pendingItems = (await _unitOfWork.LlmQueue.GetByUserAsync(userId, cancellationToken)) + .Where(r => r.Status == RequestStatus.Pending) + .OrderBy(r => r.CreatedAt) + .Take(MaxInboxItemsPerRun) + .ToList(); + + if (pendingItems.Count == 0) + { + _logger?.LogInformation("Inbox triage found no pending items for user '{UserId}'", userId); + return Result.Failure( + ErrorCodes.NotFound, "No pending inbox items to triage."); + } + + // Verify board exists + var board = await _unitOfWork.Boards.GetByIdAsync(boardId, cancellationToken); + if (board is null) + { + return Result.Failure( + ErrorCodes.NotFound, $"Board '{boardId}' not found."); + } + + // Get the first column to use as the default target + var columns = (await _unitOfWork.Columns.GetByBoardIdAsync(boardId, cancellationToken)) + .OrderBy(c => c.Position) + .ToList(); + + if (columns.Count == 0) + { + return Result.Failure( + ErrorCodes.NotFound, "Board has no columns to triage into."); + } + + var defaultColumnId = columns[0].Id; + + // Build proposal operations — one create-card per inbox item + var operations = pendingItems.Select((item, i) => + { + var parameters = JsonSerializer.Serialize(new + { + title = TruncateTitle(item.Payload), + description = $"Triaged from inbox item {item.Id}", + columnId = defaultColumnId, + boardId + }); + + return new CreateProposalOperationDto( + Sequence: i, + ActionType: "create", + TargetType: "card", + Parameters: parameters, + IdempotencyKey: $"inbox-triage:{item.Id:N}:{boardId:N}"); + }).ToList(); + + // Create the proposal — never directly mutating the board + var summary = pendingItems.Count == 1 + ? $"Inbox triage: 1 item for board '{board.Name}'" + : $"Inbox triage: {pendingItems.Count} items for board '{board.Name}'"; + + var createResult = await _proposalService.CreateProposalAsync( + new CreateProposalDto( + SourceType: ProposalSourceType.Queue, + RequestedByUserId: userId, + Summary: summary, + RiskLevel: RiskLevel.Low, + CorrelationId: Guid.NewGuid().ToString(), + BoardId: boardId, + Operations: operations), + cancellationToken); + + if (!createResult.IsSuccess) + { + _logger?.LogWarning( + "Inbox triage proposal creation failed for profile '{ProfileId}': {Error}", + agentProfileId, createResult.ErrorMessage); + return Result.Failure(createResult.ErrorCode, createResult.ErrorMessage); + } + + _logger?.LogInformation( + "Inbox triage created proposal '{ProposalId}' with {Count} operations (review required: {Review})", + createResult.Value.Id, operations.Count, policyDecision.RequiresReview); + + return Result.Success(new InboxTriageResultDto( + createResult.Value.Id, + operations.Count, + policyDecision.RequiresReview, + policyDecision.Reason)); + } + + private static string TruncateTitle(string input) + { + const int maxLength = 200; + var firstLine = input.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? input; + var trimmed = firstLine.Trim(); + return trimmed.Length > maxLength ? trimmed[..maxLength].TrimEnd() : trimmed; + } + + /// + /// Returns the built-in tool definition for registration in the tool registry. + /// + public static ITaskdeckTool GetToolDefinition() + { + return new TaskdeckToolDefinition( + Key: ToolKey, + DisplayName: "Inbox Triage", + Description: "Triages pending inbox items into card proposals for a target board.", + Scope: ToolScope.Inbox, + RiskLevel: ToolRiskLevel.Medium); + } +} + +/// +/// Result DTO for an inbox triage run. +/// +public record InboxTriageResultDto( + Guid ProposalId, + int ItemsTriaged, + bool RequiresReview, + string PolicyReason); diff --git a/backend/src/Taskdeck.Application/Services/TaskdeckToolDefinition.cs b/backend/src/Taskdeck.Application/Services/TaskdeckToolDefinition.cs new file mode 100644 index 000000000..3778d221a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/TaskdeckToolDefinition.cs @@ -0,0 +1,15 @@ +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Enums; + +namespace Taskdeck.Application.Services; + +/// +/// Concrete, immutable implementation of used +/// to register built-in tools in the tool registry. +/// +public sealed record TaskdeckToolDefinition( + string Key, + string DisplayName, + string Description, + ToolScope Scope, + ToolRiskLevel RiskLevel) : ITaskdeckTool; diff --git a/backend/src/Taskdeck.Application/Services/TaskdeckToolRegistry.cs b/backend/src/Taskdeck.Application/Services/TaskdeckToolRegistry.cs new file mode 100644 index 000000000..744ec8456 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/TaskdeckToolRegistry.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Enums; + +namespace Taskdeck.Application.Services; + +/// +/// In-memory tool registry populated at application startup. +/// Thread-safe for concurrent reads; registration is expected during startup only. +/// +public sealed class TaskdeckToolRegistry : ITaskdeckToolRegistry +{ + private readonly ConcurrentDictionary _tools = new(StringComparer.OrdinalIgnoreCase); + + public void RegisterTool(ITaskdeckTool tool) + { + ArgumentNullException.ThrowIfNull(tool); + + if (string.IsNullOrWhiteSpace(tool.Key)) + throw new ArgumentException("Tool key cannot be null or empty.", nameof(tool)); + + if (!_tools.TryAdd(tool.Key, tool)) + throw new InvalidOperationException($"A tool with key '{tool.Key}' is already registered."); + } + + public ITaskdeckTool? GetTool(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return null; + + _tools.TryGetValue(key, out var tool); + return tool; + } + + public IReadOnlyList GetAllTools() + { + return _tools.Values.OrderBy(t => t.Key, StringComparer.OrdinalIgnoreCase).ToList(); + } + + public IReadOnlyList GetToolsByScope(ToolScope scope) + { + return _tools.Values + .Where(t => t.Scope == scope) + .OrderBy(t => t.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/backend/src/Taskdeck.Domain/Agents/ITaskdeckTool.cs b/backend/src/Taskdeck.Domain/Agents/ITaskdeckTool.cs new file mode 100644 index 000000000..a3f2265b1 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Agents/ITaskdeckTool.cs @@ -0,0 +1,25 @@ +using Taskdeck.Domain.Enums; + +namespace Taskdeck.Domain.Agents; + +/// +/// Describes a tool that an agent can invoke. Tools are registered in the +/// tool registry and evaluated by the policy engine before execution. +/// +public interface ITaskdeckTool +{ + /// Unique machine-readable key (e.g. "inbox.triage", "board.create-card"). + string Key { get; } + + /// Human-readable display name shown in review UIs. + string DisplayName { get; } + + /// Short description of what this tool does. + string Description { get; } + + /// The operational scope this tool acts within. + ToolScope Scope { get; } + + /// Risk classification used by the policy evaluator. + ToolRiskLevel RiskLevel { get; } +} diff --git a/backend/src/Taskdeck.Domain/Agents/ITaskdeckToolRegistry.cs b/backend/src/Taskdeck.Domain/Agents/ITaskdeckToolRegistry.cs new file mode 100644 index 000000000..db1908b29 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Agents/ITaskdeckToolRegistry.cs @@ -0,0 +1,22 @@ +using Taskdeck.Domain.Enums; + +namespace Taskdeck.Domain.Agents; + +/// +/// Registry of all available agent tools. Populated at application startup +/// and queried by the policy evaluator and agent templates. +/// +public interface ITaskdeckToolRegistry +{ + /// Register a tool. Throws if a tool with the same key is already registered. + void RegisterTool(ITaskdeckTool tool); + + /// Look up a tool by its unique key. Returns null if not found. + ITaskdeckTool? GetTool(string key); + + /// Return all registered tools. + IReadOnlyList GetAllTools(); + + /// Return tools filtered by operational scope. + IReadOnlyList GetToolsByScope(ToolScope scope); +} diff --git a/backend/src/Taskdeck.Domain/Agents/PolicyDecision.cs b/backend/src/Taskdeck.Domain/Agents/PolicyDecision.cs new file mode 100644 index 000000000..68cba4b5b --- /dev/null +++ b/backend/src/Taskdeck.Domain/Agents/PolicyDecision.cs @@ -0,0 +1,50 @@ +namespace Taskdeck.Domain.Agents; + +/// +/// Value object representing the outcome of a policy evaluation for a tool use request. +/// Immutable by design — once produced by the policy evaluator, a decision is final. +/// +public sealed class PolicyDecision +{ + /// Whether the tool invocation is allowed to proceed. + public bool Allowed { get; } + + /// Whether the result must be routed through the review gate before applying. + public bool RequiresReview { get; } + + /// Human-readable reason for the decision, suitable for audit trails. + public string Reason { get; } + + private PolicyDecision(bool allowed, bool requiresReview, string reason) + { + Allowed = allowed; + RequiresReview = requiresReview; + Reason = reason; + } + + /// Create a decision allowing execution but requiring proposal review. + public static PolicyDecision AllowWithReview(string reason) + => new(true, true, reason); + + /// Create a decision denying execution entirely. + public static PolicyDecision Deny(string reason) + => new(false, false, reason); + + /// Create a decision allowing direct execution (low-risk, auto-apply enabled). + public static PolicyDecision AllowDirect(string reason) + => new(true, false, reason); + + public override bool Equals(object? obj) + { + if (obj is not PolicyDecision other) return false; + return Allowed == other.Allowed + && RequiresReview == other.RequiresReview + && Reason == other.Reason; + } + + public override int GetHashCode() + => HashCode.Combine(Allowed, RequiresReview, Reason); + + public override string ToString() + => $"PolicyDecision(Allowed={Allowed}, RequiresReview={RequiresReview}, Reason=\"{Reason}\")"; +} diff --git a/backend/src/Taskdeck.Domain/Enums/ToolRiskLevel.cs b/backend/src/Taskdeck.Domain/Enums/ToolRiskLevel.cs new file mode 100644 index 000000000..0b7f3f067 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Enums/ToolRiskLevel.cs @@ -0,0 +1,18 @@ +namespace Taskdeck.Domain.Enums; + +/// +/// Risk classification for agent tools. Determines policy evaluation behavior. +/// High and Medium risk tools require review by default; Low risk tools +/// are still review-first unless explicitly configured otherwise. +/// +public enum ToolRiskLevel +{ + /// Read-only or informational tools with no mutation side effects. + Low = 0, + + /// Tools that create or update entities within a bounded scope. + Medium = 1, + + /// Tools that delete, archive, or perform cross-scope mutations. + High = 2 +} diff --git a/backend/src/Taskdeck.Domain/Enums/ToolScope.cs b/backend/src/Taskdeck.Domain/Enums/ToolScope.cs new file mode 100644 index 000000000..c12bbced4 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Enums/ToolScope.cs @@ -0,0 +1,16 @@ +namespace Taskdeck.Domain.Enums; + +/// +/// Defines the operational scope of an agent tool. +/// +public enum ToolScope +{ + /// Tool operates on a specific board (columns, cards, labels). + Board = 0, + + /// Tool operates on the capture inbox (triage, categorization). + Inbox = 1, + + /// Tool operates at workspace/global level (settings, cross-board). + Global = 2 +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AgentPolicyEvaluatorTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AgentPolicyEvaluatorTests.cs new file mode 100644 index 000000000..92daaa1ec --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AgentPolicyEvaluatorTests.cs @@ -0,0 +1,282 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Tests.Support; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AgentPolicyEvaluatorTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _profileRepoMock; + private readonly TaskdeckToolRegistry _toolRegistry; + private readonly InMemoryLogger _logger; + private readonly AgentPolicyEvaluator _evaluator; + + public AgentPolicyEvaluatorTests() + { + _unitOfWorkMock = new Mock(); + _profileRepoMock = new Mock(); + _unitOfWorkMock.Setup(u => u.AgentProfiles).Returns(_profileRepoMock.Object); + + _toolRegistry = new TaskdeckToolRegistry(); + _logger = new InMemoryLogger(); + _evaluator = new AgentPolicyEvaluator(_unitOfWorkMock.Object, _toolRegistry, _logger); + } + + private static ITaskdeckTool CreateTool( + string key, ToolRiskLevel riskLevel, ToolScope scope = ToolScope.Inbox) + { + return new TaskdeckToolDefinition(key, $"Tool {key}", $"Desc for {key}", scope, riskLevel); + } + + private AgentProfile CreateProfile( + string? policyJson = null, + bool isEnabled = true) + { + var profile = new AgentProfile( + Guid.NewGuid(), + "Test Agent", + "triage-v1", + AgentScopeType.Workspace, + policyJson: policyJson); + + if (!isEnabled) + profile.SetEnabled(false); + + return profile; + } + + [Fact] + public async Task EvaluateToolUse_ShouldDeny_WhenAgentProfileIdIsEmpty() + { + var decision = await _evaluator.EvaluateToolUseAsync(Guid.Empty, "inbox.triage"); + + decision.Allowed.Should().BeFalse(); + decision.Reason.Should().Contain("required"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldDeny_WhenToolKeyIsEmpty() + { + var decision = await _evaluator.EvaluateToolUseAsync(Guid.NewGuid(), ""); + + decision.Allowed.Should().BeFalse(); + decision.Reason.Should().Contain("required"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldDeny_WhenToolNotInRegistry() + { + var profileId = Guid.NewGuid(); + + var decision = await _evaluator.EvaluateToolUseAsync(profileId, "nonexistent.tool"); + + decision.Allowed.Should().BeFalse(); + decision.Reason.Should().Contain("not registered"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldDeny_WhenProfileNotFound() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + _profileRepoMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((AgentProfile?)null); + + var decision = await _evaluator.EvaluateToolUseAsync(Guid.NewGuid(), "inbox.triage"); + + decision.Allowed.Should().BeFalse(); + decision.Reason.Should().Contain("not found"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldDeny_WhenProfileIsDisabled() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + var profile = CreateProfile(isEnabled: false); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "inbox.triage"); + + decision.Allowed.Should().BeFalse(); + decision.Reason.Should().Contain("disabled"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldDeny_WhenToolNotInAllowlist() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + _toolRegistry.RegisterTool(CreateTool("board.read", ToolRiskLevel.Low)); + + var profile = CreateProfile(policyJson: "{\"allowedTools\":[\"board.read\"]}"); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "inbox.triage"); + + decision.Allowed.Should().BeFalse(); + decision.Reason.Should().Contain("not in this agent's allowed tool list"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldAllow_WhenToolInAllowlist() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + var profile = CreateProfile(policyJson: "{\"allowedTools\":[\"inbox.triage\"]}"); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "inbox.triage"); + + decision.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task EvaluateToolUse_ShouldAllow_WhenAllowlistIsEmpty() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + var profile = CreateProfile(); // default "{}" policy - empty allowlist means all allowed + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "inbox.triage"); + + decision.Allowed.Should().BeTrue(); + } + + [Fact] + public async Task EvaluateToolUse_ShouldRequireReview_ForHighRiskTool() + { + _toolRegistry.RegisterTool(CreateTool("board.delete", ToolRiskLevel.High)); + var profile = CreateProfile(); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "board.delete"); + + decision.Allowed.Should().BeTrue(); + decision.RequiresReview.Should().BeTrue(); + decision.Reason.Should().Contain("High-risk"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldRequireReview_ForMediumRiskTool() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + var profile = CreateProfile(); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "inbox.triage"); + + decision.Allowed.Should().BeTrue(); + decision.RequiresReview.Should().BeTrue(); + decision.Reason.Should().Contain("Medium-risk"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldRequireReview_ForLowRiskTool_ByDefault() + { + _toolRegistry.RegisterTool(CreateTool("board.read-cards", ToolRiskLevel.Low)); + var profile = CreateProfile(); // no autoApplyLowRisk + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "board.read-cards"); + + decision.Allowed.Should().BeTrue(); + decision.RequiresReview.Should().BeTrue(); + decision.Reason.Should().Contain("auto-apply is off"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldAllowDirect_ForLowRiskTool_WhenAutoApplyEnabled() + { + _toolRegistry.RegisterTool(CreateTool("board.read-cards", ToolRiskLevel.Low)); + var profile = CreateProfile(policyJson: "{\"autoApplyLowRisk\":true}"); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "board.read-cards"); + + decision.Allowed.Should().BeTrue(); + decision.RequiresReview.Should().BeFalse(); + decision.Reason.Should().Contain("auto-applied"); + } + + [Fact] + public async Task EvaluateToolUse_ShouldStillRequireReview_ForHighRisk_EvenWithAutoApply() + { + _toolRegistry.RegisterTool(CreateTool("board.delete", ToolRiskLevel.High)); + var profile = CreateProfile(policyJson: "{\"autoApplyLowRisk\":true}"); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + var decision = await _evaluator.EvaluateToolUseAsync(profile.Id, "board.delete"); + + decision.Allowed.Should().BeTrue(); + decision.RequiresReview.Should().BeTrue(); + } + + [Fact] + public async Task EvaluateToolUse_ShouldLogDecisions() + { + _toolRegistry.RegisterTool(CreateTool("inbox.triage", ToolRiskLevel.Medium)); + var profile = CreateProfile(); + _profileRepoMock.Setup(r => r.GetByIdAsync(profile.Id, It.IsAny())) + .ReturnsAsync(profile); + + await _evaluator.EvaluateToolUseAsync(profile.Id, "inbox.triage"); + + _logger.Entries.Should().NotBeEmpty(); + _logger.Entries.Should().Contain(e => e.Message.Contains("inbox.triage")); + } + + #region ParsePolicy edge cases + + [Fact] + public void ParsePolicy_ShouldReturnDefaults_ForEmptyJson() + { + var config = AgentPolicyEvaluator.ParsePolicy("{}"); + + config.AllowedTools.Should().BeEmpty(); + config.AutoApplyLowRisk.Should().BeFalse(); + } + + [Fact] + public void ParsePolicy_ShouldReturnDefaults_ForMalformedJson() + { + var config = AgentPolicyEvaluator.ParsePolicy("not json"); + + config.AllowedTools.Should().BeEmpty(); + config.AutoApplyLowRisk.Should().BeFalse(); + } + + [Fact] + public void ParsePolicy_ShouldReturnDefaults_ForNull() + { + var config = AgentPolicyEvaluator.ParsePolicy(null); + + config.AllowedTools.Should().BeEmpty(); + config.AutoApplyLowRisk.Should().BeFalse(); + } + + [Fact] + public void ParsePolicy_ShouldParseAllowedTools() + { + var config = AgentPolicyEvaluator.ParsePolicy( + "{\"allowedTools\":[\"inbox.triage\",\"board.read\"]}"); + + config.AllowedTools.Should().HaveCount(2); + config.AllowedTools.Should().Contain("inbox.triage"); + config.AllowedTools.Should().Contain("board.read"); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/InboxTriageAssistantTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/InboxTriageAssistantTests.cs new file mode 100644 index 000000000..87d2d4998 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/InboxTriageAssistantTests.cs @@ -0,0 +1,280 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; +using Taskdeck.Tests.Support; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class InboxTriageAssistantTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _policyMock; + private readonly Mock _proposalServiceMock; + private readonly Mock _llmQueueRepoMock; + private readonly Mock _boardRepoMock; + private readonly Mock _columnRepoMock; + private readonly InMemoryLogger _logger; + private readonly InboxTriageAssistant _assistant; + + private readonly Guid _agentProfileId = Guid.NewGuid(); + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _boardId = Guid.NewGuid(); + + public InboxTriageAssistantTests() + { + _unitOfWorkMock = new Mock(); + _policyMock = new Mock(); + _proposalServiceMock = new Mock(); + _llmQueueRepoMock = new Mock(); + _boardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _logger = new InMemoryLogger(); + + _unitOfWorkMock.Setup(u => u.LlmQueue).Returns(_llmQueueRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + + _assistant = new InboxTriageAssistant( + _unitOfWorkMock.Object, + _policyMock.Object, + _proposalServiceMock.Object, + _logger); + } + + private void SetupPolicyAllow() + { + _policyMock.Setup(p => p.EvaluateToolUseAsync( + _agentProfileId, InboxTriageAssistant.ToolKey, It.IsAny?>(), It.IsAny())) + .ReturnsAsync(PolicyDecision.AllowWithReview("Medium-risk tool requires review.")); + } + + private void SetupPolicyDeny(string reason = "Denied by policy.") + { + _policyMock.Setup(p => p.EvaluateToolUseAsync( + _agentProfileId, InboxTriageAssistant.ToolKey, It.IsAny?>(), It.IsAny())) + .ReturnsAsync(PolicyDecision.Deny(reason)); + } + + private void SetupInboxItems(int count) + { + var items = Enumerable.Range(0, count) + .Select(_ => new LlmRequest(_userId, "capture", "Task item text")) + .ToList(); + + _llmQueueRepoMock.Setup(r => r.GetByUserAsync(_userId, It.IsAny())) + .ReturnsAsync(items); + } + + private void SetupBoardWithColumn() + { + var board = TestDataBuilder.CreateBoard("Test Board"); + _boardRepoMock.Setup(r => r.GetByIdAsync(_boardId, It.IsAny())) + .ReturnsAsync(board); + + var column = TestDataBuilder.CreateColumn(_boardId, "To Do", 0); + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(_boardId, It.IsAny())) + .ReturnsAsync(new[] { column }); + } + + private void SetupProposalCreationSuccess() + { + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CreateProposalDto dto, CancellationToken _) => + { + var proposal = new ProposalDto( + Guid.NewGuid(), + dto.SourceType, + dto.SourceReferenceId, + dto.BoardId, + dto.RequestedByUserId, + ProposalStatus.PendingReview, + dto.RiskLevel, + dto.Summary, + null, null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, null, null, null, + dto.CorrelationId, + new List()); + return Result.Success(proposal); + }); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenAgentProfileIdIsEmpty() + { + var result = await _assistant.RunTriageAsync(Guid.Empty, _userId, _boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenUserIdIsEmpty() + { + var result = await _assistant.RunTriageAsync(_agentProfileId, Guid.Empty, _boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenBoardIdIsEmpty() + { + var result = await _assistant.RunTriageAsync(_agentProfileId, _userId, Guid.Empty); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenPolicyDenies() + { + SetupPolicyDeny("Tool not in allowlist."); + + var result = await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + result.ErrorMessage.Should().Contain("not in allowlist"); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenNoInboxItems() + { + SetupPolicyAllow(); + SetupInboxItems(0); + + var result = await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenBoardNotFound() + { + SetupPolicyAllow(); + SetupInboxItems(2); + _boardRepoMock.Setup(r => r.GetByIdAsync(_boardId, It.IsAny())) + .ReturnsAsync((Board?)null); + + var result = await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task RunTriage_ShouldFail_WhenBoardHasNoColumns() + { + SetupPolicyAllow(); + SetupInboxItems(2); + + var board = TestDataBuilder.CreateBoard("Test Board"); + _boardRepoMock.Setup(r => r.GetByIdAsync(_boardId, It.IsAny())) + .ReturnsAsync(board); + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(_boardId, It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + result.ErrorMessage.Should().Contain("no columns"); + } + + [Fact] + public async Task RunTriage_ShouldCreateProposal_WithCorrectItemCount() + { + SetupPolicyAllow(); + SetupInboxItems(3); + SetupBoardWithColumn(); + SetupProposalCreationSuccess(); + + var result = await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsTriaged.Should().Be(3); + result.Value.RequiresReview.Should().BeTrue(); + result.Value.ProposalId.Should().NotBeEmpty(); + } + + [Fact] + public async Task RunTriage_ShouldNeverDirectlyMutateBoard() + { + SetupPolicyAllow(); + SetupInboxItems(2); + SetupBoardWithColumn(); + SetupProposalCreationSuccess(); + + await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + // Verify that the assistant never called SaveChangesAsync (no direct mutation) + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + + // Verify that proposal was created (the review-first path) + _proposalServiceMock.Verify( + s => s.CreateProposalAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RunTriage_ShouldRouteProposalThroughPolicyEvaluator() + { + SetupPolicyAllow(); + SetupInboxItems(1); + SetupBoardWithColumn(); + SetupProposalCreationSuccess(); + + await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + _policyMock.Verify( + p => p.EvaluateToolUseAsync( + _agentProfileId, + InboxTriageAssistant.ToolKey, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RunTriage_ShouldPassBoardIdInPolicyContext() + { + SetupPolicyAllow(); + SetupInboxItems(1); + SetupBoardWithColumn(); + SetupProposalCreationSuccess(); + + await _assistant.RunTriageAsync(_agentProfileId, _userId, _boardId); + + _policyMock.Verify( + p => p.EvaluateToolUseAsync( + _agentProfileId, + InboxTriageAssistant.ToolKey, + It.Is>(ctx => ctx.ContainsKey("boardId")), + It.IsAny()), + Times.Once); + } + + [Fact] + public void GetToolDefinition_ShouldReturnCorrectDefinition() + { + var tool = InboxTriageAssistant.GetToolDefinition(); + + tool.Key.Should().Be("inbox.triage"); + tool.DisplayName.Should().Be("Inbox Triage"); + tool.Scope.Should().Be(ToolScope.Inbox); + tool.RiskLevel.Should().Be(ToolRiskLevel.Medium); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ToolRegistryTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ToolRegistryTests.cs new file mode 100644 index 000000000..e9a442d00 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/ToolRegistryTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Agents; +using Taskdeck.Domain.Enums; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class ToolRegistryTests +{ + private readonly TaskdeckToolRegistry _registry = new(); + + private static ITaskdeckTool CreateTool( + string key = "test.tool", + string displayName = "Test Tool", + string description = "A test tool", + ToolScope scope = ToolScope.Board, + ToolRiskLevel riskLevel = ToolRiskLevel.Low) + { + return new TaskdeckToolDefinition(key, displayName, description, scope, riskLevel); + } + + [Fact] + public void RegisterTool_ShouldSucceed_ForNewKey() + { + var tool = CreateTool(); + + var act = () => _registry.RegisterTool(tool); + + act.Should().NotThrow(); + } + + [Fact] + public void RegisterTool_ShouldThrow_ForDuplicateKey() + { + _registry.RegisterTool(CreateTool("dup.key")); + + var act = () => _registry.RegisterTool(CreateTool("dup.key")); + + act.Should().Throw() + .WithMessage("*dup.key*already registered*"); + } + + [Fact] + public void RegisterTool_ShouldThrow_ForNullTool() + { + var act = () => _registry.RegisterTool(null!); + + act.Should().Throw(); + } + + [Fact] + public void RegisterTool_ShouldThrow_ForEmptyKey() + { + var tool = CreateTool(key: ""); + + var act = () => _registry.RegisterTool(tool); + + act.Should().Throw(); + } + + [Fact] + public void GetTool_ShouldReturnRegisteredTool() + { + var tool = CreateTool("board.create-card"); + _registry.RegisterTool(tool); + + var result = _registry.GetTool("board.create-card"); + + result.Should().NotBeNull(); + result!.Key.Should().Be("board.create-card"); + } + + [Fact] + public void GetTool_ShouldReturnNull_ForUnknownKey() + { + var result = _registry.GetTool("nonexistent"); + + result.Should().BeNull(); + } + + [Fact] + public void GetTool_ShouldReturnNull_ForNullOrEmptyKey() + { + _registry.GetTool(null!).Should().BeNull(); + _registry.GetTool("").Should().BeNull(); + _registry.GetTool(" ").Should().BeNull(); + } + + [Fact] + public void GetTool_ShouldBeCaseInsensitive() + { + _registry.RegisterTool(CreateTool("Board.CreateCard")); + + _registry.GetTool("board.createcard").Should().NotBeNull(); + _registry.GetTool("BOARD.CREATECARD").Should().NotBeNull(); + } + + [Fact] + public void GetAllTools_ShouldReturnEmpty_WhenNoToolsRegistered() + { + _registry.GetAllTools().Should().BeEmpty(); + } + + [Fact] + public void GetAllTools_ShouldReturnAllRegisteredTools() + { + _registry.RegisterTool(CreateTool("b.tool")); + _registry.RegisterTool(CreateTool("a.tool")); + _registry.RegisterTool(CreateTool("c.tool")); + + var all = _registry.GetAllTools(); + + all.Should().HaveCount(3); + all.Select(t => t.Key).Should().BeInAscendingOrder(); + } + + [Fact] + public void GetToolsByScope_ShouldFilterByScope() + { + _registry.RegisterTool(CreateTool("board.one", scope: ToolScope.Board)); + _registry.RegisterTool(CreateTool("inbox.one", scope: ToolScope.Inbox)); + _registry.RegisterTool(CreateTool("board.two", scope: ToolScope.Board)); + _registry.RegisterTool(CreateTool("global.one", scope: ToolScope.Global)); + + var boardTools = _registry.GetToolsByScope(ToolScope.Board); + var inboxTools = _registry.GetToolsByScope(ToolScope.Inbox); + var globalTools = _registry.GetToolsByScope(ToolScope.Global); + + boardTools.Should().HaveCount(2); + inboxTools.Should().HaveCount(1); + globalTools.Should().HaveCount(1); + } + + [Fact] + public void GetToolsByScope_ShouldReturnEmpty_WhenNoToolsMatchScope() + { + _registry.RegisterTool(CreateTool("board.one", scope: ToolScope.Board)); + + _registry.GetToolsByScope(ToolScope.Global).Should().BeEmpty(); + } +}