From e2e496bacd7ebe7451b2e2269c89a014fb4a5334 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 04:24:41 +0100 Subject: [PATCH 1/5] Add agent tool domain types: ToolScope, ToolRiskLevel enums, ITaskdeckTool, ITaskdeckToolRegistry interfaces, and PolicyDecision value object Part of #337 (AGT-02). These domain primitives define the tool abstraction, scope/risk classification, and policy decision contract that the Application layer policy evaluator and tool registry build on. --- .../Taskdeck.Domain/Agents/ITaskdeckTool.cs | 25 ++++++++++ .../Agents/ITaskdeckToolRegistry.cs | 22 ++++++++ .../Taskdeck.Domain/Agents/PolicyDecision.cs | 50 +++++++++++++++++++ .../Taskdeck.Domain/Enums/ToolRiskLevel.cs | 18 +++++++ .../src/Taskdeck.Domain/Enums/ToolScope.cs | 16 ++++++ 5 files changed, 131 insertions(+) create mode 100644 backend/src/Taskdeck.Domain/Agents/ITaskdeckTool.cs create mode 100644 backend/src/Taskdeck.Domain/Agents/ITaskdeckToolRegistry.cs create mode 100644 backend/src/Taskdeck.Domain/Agents/PolicyDecision.cs create mode 100644 backend/src/Taskdeck.Domain/Enums/ToolRiskLevel.cs create mode 100644 backend/src/Taskdeck.Domain/Enums/ToolScope.cs 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 +} From a45f14ef5ac0987f99e38b046d108c0fa39487af Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 04:27:02 +0100 Subject: [PATCH 2/5] Add tool registry, policy evaluator, and inbox triage assistant Implements the Application layer for AGT-02 (#337): - IAgentPolicyEvaluator interface for tool-use policy checks - TaskdeckToolRegistry: in-memory, thread-safe tool registry - AgentPolicyEvaluator: allowlist enforcement, risk-level review gating, auto-apply OFF by default for all risk levels - TaskdeckToolDefinition: concrete ITaskdeckTool record - InboxTriageAssistant: bounded template that creates proposals from pending inbox items, never directly mutating board state --- .../Services/AgentPolicyEvaluator.cs | 167 +++++++++++++++ .../Services/IAgentPolicyEvaluator.cs | 25 +++ .../Services/InboxTriageAssistant.cs | 197 ++++++++++++++++++ .../Services/TaskdeckToolDefinition.cs | 15 ++ .../Services/TaskdeckToolRegistry.cs | 47 +++++ 5 files changed, 451 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs create mode 100644 backend/src/Taskdeck.Application/Services/IAgentPolicyEvaluator.cs create mode 100644 backend/src/Taskdeck.Application/Services/InboxTriageAssistant.cs create mode 100644 backend/src/Taskdeck.Application/Services/TaskdeckToolDefinition.cs create mode 100644 backend/src/Taskdeck.Application/Services/TaskdeckToolRegistry.cs diff --git a/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs b/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs new file mode 100644 index 000000000..86edeee45 --- /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 + { + 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(); + } +} From 2c7c1db9c28dbc755ec8a3e940fa17b54a1427e9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 04:30:40 +0100 Subject: [PATCH 3/5] Add tests for tool registry, policy evaluator, and inbox triage assistant 42 tests covering: - ToolRegistryTests: registration, duplicate rejection, lookup, case-insensitive keys, scope filtering - AgentPolicyEvaluatorTests: allowlist enforcement, risk-level review gating (high/medium always review, low review by default), auto-apply opt-in, disabled profile denial, policy JSON parsing - InboxTriageAssistantTests: proposal creation, no direct board mutation, policy routing, validation, edge cases --- .../Services/AgentPolicyEvaluatorTests.cs | 282 ++++++++++++++++++ .../Services/InboxTriageAssistantTests.cs | 280 +++++++++++++++++ .../Services/ToolRegistryTests.cs | 142 +++++++++ 3 files changed, 704 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/AgentPolicyEvaluatorTests.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/InboxTriageAssistantTests.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/ToolRegistryTests.cs 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(); + } +} From e4792c8ed8dcda0a372ab7469960b0923e38e2cf Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 04:31:50 +0100 Subject: [PATCH 4/5] Fix JsonDocument disposal in AgentPolicyEvaluator.ParsePolicy Add missing `using` on JsonDocument.Parse to prevent resource leak. Found during adversarial self-review. --- .../src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs b/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs index 86edeee45..852e5e58a 100644 --- a/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs +++ b/backend/src/Taskdeck.Application/Services/AgentPolicyEvaluator.cs @@ -126,7 +126,7 @@ internal static AgentPolicyConfig ParsePolicy(string? policyJson) try { - var doc = JsonDocument.Parse(policyJson); + using var doc = JsonDocument.Parse(policyJson); var root = doc.RootElement; var allowedTools = new List(); From e2a5118a00fdb07a2cf45ef2be5380148ccfad1f Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 04:39:40 +0100 Subject: [PATCH 5/5] Register AGT-02 services in DI container and wire inbox triage tool The tool registry, policy evaluator, and inbox triage assistant were missing DI registrations, making the entire feature unresolvable at runtime. Also registers the inbox.triage tool definition at startup so the policy evaluator can find it. --- .../Extensions/ApplicationServiceRegistration.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) 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; } }