From 5e253f601ce12a3aca66cf57989bfd367c502b31 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 5 Jun 2026 17:39:01 -0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20chat=20AI=20tools=20=E2=80=94=20ta?= =?UTF-8?q?g=20CRUD,=20full=20executor=20routing,=20declarative=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles four correlated chat-agent changes: - #83: tag chat tools (list/create/update/delete_tag) delegating to existing MediatR handlers, registered in DI and catalogued under tags.read/write/delete. - #89: ReorderGoalsTool (mirrors ReorderHabitsTool) + unit tests for the new/write tag and goal tools; reorder_goals added to GoalsWrite.chatTools. - #88: full executor routing — every mutating MCP method (GoalTools, TagTools, ProfileTools, NotificationTools, UserFactTools, SubscriptionTools) now routes through McpExecutorBridge → IAgentOperationExecutor for shared policy + audit, mapping mismatched methods to their consolidated chat ops. Hard cases routed too: assign_tags via a new tag_ids id-path on AssignTagsTool (id-based, replace-all, no auto-create; MCP id contract unchanged) and get_referral_code via a new GetReferralCodeTool + ReferralsWrite capability + WriteReferrals scope (added to ClaudeDefaultScopes). Destructive routed deletes (goal/tag/notification/user-fact) accept and forward a confirmation token. No mutating MCP method remains on direct MediatR. update_goal_progress widened to accept goal_id so its MCP method can route. - #87: tool ordering is now data — int Order default-interface member on IAiTool (create_habit=0, create_sub_habit=1, assign_tags=2, default int.MaxValue); the hardcoded switch in ProcessUserChatCommand is replaced by the registry lookup. Adds Chat/Tools/README.md documenting the contract, Order, catalog invariant, and MCP-routing relationship. Existing MCP toolset unit tests migrated to the executor-routed pattern. Refs thomasluizon/orbit-ui-mobile#83, thomasluizon/orbit-ui-mobile#89, thomasluizon/orbit-ui-mobile#88, thomasluizon/orbit-ui-mobile#87 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Extensions/ServiceCollectionExtensions.cs | 6 + src/Orbit.Api/Mcp/Tools/GoalTools.cs | 129 ++++---- src/Orbit.Api/Mcp/Tools/NotificationTools.cs | 47 +-- src/Orbit.Api/Mcp/Tools/ProfileTools.cs | 87 +++--- src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs | 22 +- src/Orbit.Api/Mcp/Tools/TagTools.cs | 64 ++-- src/Orbit.Api/Mcp/Tools/UserFactTools.cs | 24 +- .../Chat/Commands/ProcessUserChatCommand.cs | 13 +- src/Orbit.Application/Chat/Tools/IAiTool.cs | 1 + .../Tools/Implementations/AssignTagsTool.cs | 58 +++- .../Tools/Implementations/CreateHabitTool.cs | 2 + .../Implementations/CreateSubHabitTool.cs | 2 + .../Tools/Implementations/PlatformTools.cs | 21 ++ .../Tools/Implementations/ReorderGoalsTool.cs | 62 ++++ .../Chat/Tools/Implementations/TagTools.cs | 120 ++++++++ .../Implementations/UpdateGoalProgressTool.cs | 37 ++- src/Orbit.Application/Chat/Tools/README.md | 62 ++++ src/Orbit.Domain/Models/AgentContracts.cs | 3 + .../Services/AgentCatalogService.cs | 23 +- .../Chat/Tools/AssignTagsToolTests.cs | 44 ++- .../Chat/Tools/ChatToolMetadataTests.cs | 13 + .../Chat/Tools/TagToolTests.cs | 220 ++++++++++++++ .../Chat/Tools/UpdateGoalProgressToolTests.cs | 22 +- .../ProcessUserChatCommandHandlerTests.cs | 56 ++++ .../Mcp/GoalToolsTests.cs | 121 +++++--- .../Mcp/NotificationToolsTests.cs | 73 +++-- .../Mcp/ProfileToolsTests.cs | 122 +++++--- .../Mcp/SubscriptionToolsTests.cs | 28 +- .../Mcp/TagToolsTests.cs | 90 ++++-- .../Mcp/UserFactToolsTests.cs | 50 +++- .../McpMutationExecutorRoutingTests.cs | 276 ++++++++++++++++++ 31 files changed, 1552 insertions(+), 346 deletions(-) create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/ReorderGoalsTool.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/TagTools.cs create mode 100644 src/Orbit.Application/Chat/Tools/README.md create mode 100644 tests/Orbit.Application.Tests/Chat/Tools/TagToolTests.cs create mode 100644 tests/Orbit.IntegrationTests/McpMutationExecutorRoutingTests.cs diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 912cc679..d38e7cad 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -214,6 +214,12 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/Orbit.Api/Mcp/Tools/GoalTools.cs b/src/Orbit.Api/Mcp/Tools/GoalTools.cs index d6ac8a19..e8d43453 100644 --- a/src/Orbit.Api/Mcp/Tools/GoalTools.cs +++ b/src/Orbit.Api/Mcp/Tools/GoalTools.cs @@ -1,16 +1,23 @@ using System.ComponentModel; -using System.Globalization; using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.Goals.Commands; using Orbit.Application.Goals.Queries; using Orbit.Domain.Enums; namespace Orbit.Api.Mcp.Tools; +/// +/// MCP goal tools. Mutations route through → +/// with +/// , sharing the policy evaluation +/// (read-only-credential denial, ownership pre-check, Pro/feature-flag gating) and the +/// AgentAuditLogs trail used by every other agent surface; each forwards a snake_case +/// argument object matching its backing IAiTool schema and formats the result into the +/// legacy string contract. Read/query tools stay on MediatR. +/// [McpServerToolType] -public class GoalTools(IMediator mediator) +public class GoalTools(IMediator mediator, McpExecutorBridge executorBridge) { private static readonly System.Text.Json.JsonSerializerOptions CaseInsensitiveJsonOptions = new() { PropertyNameCaseInsensitive = true }; [McpServerTool(Name = "list_goals"), Description("List all goals for the authenticated user.")] @@ -55,24 +62,19 @@ public async Task CreateGoal( [Description("Goal type: Standard (default) or Streak")] string type = "Standard", CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var goalType = Enum.TryParse(type, ignoreCase: true, out var parsedType) - ? parsedType - : GoalType.Standard; - - var command = new CreateGoalCommand( - userId, + var result = await executorBridge.ExecuteAsync(user, "create_goal", new + { title, - description, - targetValue, + target_value = targetValue, unit, - McpInputParser.ParseOptionalDate(deadline, "deadline"), - Type: goalType); + description, + deadline, + goal_type = type + }, confirmationToken: null, cancellationToken); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Created goal '{title}' (id: {result.Value})" - : $"Error: {result.Error}"; + return result.Succeeded + ? $"Created goal '{title}' (id: {result.TargetId})" + : result.Message; } [McpServerTool(Name = "get_goal"), Description("Get detailed information about a specific goal by ID, including progress history and linked habits.")] @@ -113,34 +115,32 @@ public async Task UpdateGoal( [Description("Optional deadline in YYYY-MM-DD format")] string? deadline = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new UpdateGoalCommand( - userId, - McpInputParser.ParseGuid(goalId, "goalId"), + var result = await executorBridge.ExecuteAsync(user, "update_goal", new + { + goal_id = goalId, title, - description, - targetValue, + target_value = targetValue, unit, - McpInputParser.ParseOptionalDate(deadline, "deadline")); + description, + deadline + }, confirmationToken: null, cancellationToken); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Updated goal {goalId}" - : $"Error: {result.Error}"; + return result.Succeeded ? $"Updated goal {goalId}" : result.Message; } [McpServerTool(Name = "delete_goal"), Description("Delete a goal by ID.")] public async Task DeleteGoal( ClaimsPrincipal user, [Description("The goal ID (GUID)")] string goalId, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: deleting a goal is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new DeleteGoalCommand(userId, McpInputParser.ParseGuid(goalId, "goalId")); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Deleted goal {goalId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "delete_goal", new + { + goal_id = goalId + }, confirmationToken, cancellationToken); + + return result.Succeeded ? $"Deleted goal {goalId}" : result.Message; } [McpServerTool(Name = "update_goal_progress"), Description("Update a goal's current progress value.")] @@ -151,17 +151,16 @@ public async Task UpdateGoalProgress( [Description("Optional note about this progress update")] string? note = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new UpdateGoalProgressCommand( - userId, - McpInputParser.ParseGuid(goalId, "goalId"), - currentValue, - note); - - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess + var result = await executorBridge.ExecuteAsync(user, "update_goal_progress", new + { + goal_id = goalId, + current_value = currentValue, + note + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Updated progress for goal {goalId} to {currentValue}" - : $"Error: {result.Error}"; + : result.Message; } [McpServerTool(Name = "update_goal_status"), Description("Change a goal's status (Active, Completed, or Abandoned).")] @@ -171,13 +170,13 @@ public async Task UpdateGoalStatus( [Description("New status: Active, Completed, or Abandoned")] string status, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var newStatus = Enum.Parse(status, true); - var command = new UpdateGoalStatusCommand(userId, McpInputParser.ParseGuid(goalId, "goalId"), newStatus); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Updated goal {goalId} status to {status}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "update_goal_status", new + { + goal_id = goalId, + status + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Updated goal {goalId} status to {status}" : result.Message; } [McpServerTool(Name = "reorder_goals"), Description("Reorder goals by setting new positions.")] @@ -186,18 +185,17 @@ public async Task ReorderGoals( [Description("JSON array of objects with 'id' (GUID) and 'position' (int)")] string positionsJson, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); var items = System.Text.Json.JsonSerializer.Deserialize>( positionsJson, CaseInsensitiveJsonOptions) ?? []; - var positions = items.Select(p => new GoalPositionUpdate(McpInputParser.ParseGuid(p.Id, "id"), p.Position)).ToList(); - var command = new ReorderGoalsCommand(userId, positions); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Reordered {positions.Count} goals" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "reorder_goals", new + { + positions = items.Select(p => new { goal_id = p.Id, position = p.Position }) + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Reordered {items.Count} goals" : result.Message; } [McpServerTool(Name = "link_habits_to_goal"), Description("Link habits to a goal. Pass the full list of habit IDs (replaces existing links).")] @@ -207,15 +205,16 @@ public async Task LinkHabitsToGoal( [Description("Comma-separated habit IDs (GUIDs)")] string habitIds, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); var ids = habitIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => McpInputParser.ParseGuid(s, "habitIds")).ToList(); - var command = new LinkHabitsToGoalCommand(userId, McpInputParser.ParseGuid(goalId, "goalId"), ids); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Linked {ids.Count} habits to goal {goalId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "link_habits_to_goal", new + { + goal_id = goalId, + habit_ids = ids.Select(i => i.ToString()) + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Linked {ids.Count} habits to goal {goalId}" : result.Message; } [McpServerTool(Name = "get_goal_metrics"), Description("Get metrics for a goal: progress percentage, velocity, projected completion, and linked habit adherence.")] diff --git a/src/Orbit.Api/Mcp/Tools/NotificationTools.cs b/src/Orbit.Api/Mcp/Tools/NotificationTools.cs index 3414f590..9b2151b0 100644 --- a/src/Orbit.Api/Mcp/Tools/NotificationTools.cs +++ b/src/Orbit.Api/Mcp/Tools/NotificationTools.cs @@ -2,13 +2,21 @@ using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.Notifications.Commands; using Orbit.Application.Notifications.Queries; namespace Orbit.Api.Mcp.Tools; +/// +/// MCP notification tools. Mutations route through → +/// with +/// for shared policy evaluation and the +/// AgentAuditLogs trail. mark_notification_read/mark_all_notifications_read map +/// to the consolidated update_notifications chat tool and delete_notification maps to +/// delete_notifications, each via an action discriminator; the destructive delete +/// accepts and forwards a confirmation token. The get_notifications read stays on MediatR. +/// [McpServerToolType] -public class NotificationTools(IMediator mediator) +public class NotificationTools(IMediator mediator, McpExecutorBridge executorBridge) { [McpServerTool(Name = "get_notifications"), Description("Get the user's notifications (up to 50, newest first) with unread count.")] public async Task GetNotifications( @@ -37,13 +45,13 @@ public async Task MarkNotificationRead( [Description("The notification ID (GUID)")] string notificationId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new MarkNotificationReadCommand(userId, McpInputParser.ParseGuid(notificationId, "notificationId")); - var result = await mediator.Send(command, cancellationToken); + var result = await executorBridge.ExecuteAsync(user, "update_notifications", new + { + action = "mark_read", + notification_id = notificationId + }, confirmationToken: null, cancellationToken); - return result.IsSuccess - ? $"Marked notification {notificationId} as read." - : $"Error: {result.Error}"; + return result.Succeeded ? $"Marked notification {notificationId} as read." : result.Message; } [McpServerTool(Name = "mark_all_notifications_read"), Description("Mark all notifications as read.")] @@ -51,27 +59,28 @@ public async Task MarkAllNotificationsRead( ClaimsPrincipal user, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var result = await mediator.Send(new MarkAllNotificationsReadCommand(userId), cancellationToken); + var result = await executorBridge.ExecuteAsync(user, "update_notifications", new + { + action = "mark_all_read" + }, confirmationToken: null, cancellationToken); - return result.IsSuccess - ? $"Marked {result.Value} notifications as read." - : $"Error: {result.Error}"; + return result.Succeeded ? "Marked all notifications as read." : result.Message; } [McpServerTool(Name = "delete_notification"), Description("Delete a specific notification.")] public async Task DeleteNotification( ClaimsPrincipal user, [Description("The notification ID (GUID)")] string notificationId, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: deleting a notification is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new DeleteNotificationCommand(userId, McpInputParser.ParseGuid(notificationId, "notificationId")); - var result = await mediator.Send(command, cancellationToken); + var result = await executorBridge.ExecuteAsync(user, "delete_notifications", new + { + action = "delete_one", + notification_id = notificationId + }, confirmationToken, cancellationToken); - return result.IsSuccess - ? $"Deleted notification {notificationId}." - : $"Error: {result.Error}"; + return result.Succeeded ? $"Deleted notification {notificationId}." : result.Message; } private static Guid GetUserId(ClaimsPrincipal user) diff --git a/src/Orbit.Api/Mcp/Tools/ProfileTools.cs b/src/Orbit.Api/Mcp/Tools/ProfileTools.cs index 8041bacd..940c1981 100644 --- a/src/Orbit.Api/Mcp/Tools/ProfileTools.cs +++ b/src/Orbit.Api/Mcp/Tools/ProfileTools.cs @@ -2,13 +2,21 @@ using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.Profile.Commands; using Orbit.Application.Profile.Queries; namespace Orbit.Api.Mcp.Tools; +/// +/// MCP profile tools. Mutations route through → +/// with +/// for shared policy evaluation and the +/// AgentAuditLogs trail. set_ai_memory/set_ai_summary/set_color_scheme +/// route to like-named chat tools; set_timezone/set_language/set_week_start_day +/// map to the consolidated update_profile_preferences chat tool via its action +/// discriminator. The get_profile read stays on MediatR. +/// [McpServerToolType] -public class ProfileTools(IMediator mediator) +public class ProfileTools(IMediator mediator, McpExecutorBridge executorBridge) { [McpServerTool(Name = "get_profile"), Description("Get the authenticated user's profile information.")] public async Task GetProfile( @@ -40,12 +48,13 @@ public async Task SetTimezone( [Description("IANA timezone identifier (e.g., America/New_York, Europe/London)")] string timezone, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SetTimezoneCommand(userId, timezone); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Timezone set to {timezone}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "update_profile_preferences", new + { + action = "set_timezone", + timezone + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Timezone set to {timezone}" : result.Message; } [McpServerTool(Name = "set_language"), Description("Set the user's preferred language.")] @@ -54,12 +63,13 @@ public async Task SetLanguage( [Description("Language code (e.g., en, pt-BR)")] string language, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SetLanguageCommand(userId, language); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Language set to {language}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "update_profile_preferences", new + { + action = "set_language", + language + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Language set to {language}" : result.Message; } [McpServerTool(Name = "set_ai_memory"), Description("Enable or disable AI memory (remembering user facts from conversations).")] @@ -68,11 +78,13 @@ public async Task SetAiMemory( [Description("True to enable, false to disable")] bool enabled, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SetAiMemoryCommand(userId, enabled); - var result = await mediator.Send(command, cancellationToken); - if (!result.IsSuccess) - return $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "set_ai_memory", new + { + enabled + }, confirmationToken: null, cancellationToken); + + if (!result.Succeeded) + return result.Message; return enabled ? "AI memory enabled" : "AI memory disabled"; } @@ -83,11 +95,13 @@ public async Task SetAiSummary( [Description("True to enable, false to disable")] bool enabled, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SetAiSummaryCommand(userId, enabled); - var result = await mediator.Send(command, cancellationToken); - if (!result.IsSuccess) - return $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "set_ai_summary", new + { + enabled + }, confirmationToken: null, cancellationToken); + + if (!result.Succeeded) + return result.Message; return enabled ? "AI summary enabled" : "AI summary disabled"; } @@ -98,11 +112,13 @@ public async Task SetColorScheme( [Description("Color scheme key, or null/default to clear it")] string? colorScheme, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SetColorSchemeCommand(userId, colorScheme); - var result = await mediator.Send(command, cancellationToken); - if (!result.IsSuccess) - return $"Error: {result.Error}"; + // color_scheme must be sent even when null (null clears it); an explicit JsonObject + // survives the bridge's WhenWritingNull serialization, where an anonymous null member is dropped. + var arguments = new System.Text.Json.Nodes.JsonObject { ["color_scheme"] = colorScheme }; + var result = await executorBridge.ExecuteAsync(user, "set_color_scheme", arguments, confirmationToken: null, cancellationToken); + + if (!result.Succeeded) + return result.Message; return $"Color scheme set to {colorScheme ?? "default"}"; } @@ -113,11 +129,14 @@ public async Task SetWeekStartDay( [Description("0 for Sunday, 1 for Monday")] int weekStartDay, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SetWeekStartDayCommand(userId, weekStartDay); - var result = await mediator.Send(command, cancellationToken); - if (!result.IsSuccess) - return $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "update_profile_preferences", new + { + action = "set_week_start_day", + week_start_day = weekStartDay + }, confirmationToken: null, cancellationToken); + + if (!result.Succeeded) + return result.Message; return weekStartDay == 0 ? "Week start day set to Sunday" : "Week start day set to Monday"; } diff --git a/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs b/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs index b7db653c..624ebf08 100644 --- a/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs +++ b/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs @@ -4,19 +4,27 @@ using Microsoft.Extensions.Options; using ModelContextProtocol.Server; using Orbit.Application.Common; -using Orbit.Application.Referrals.Commands; using Orbit.Application.Referrals.Queries; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; namespace Orbit.Api.Mcp.Tools; +/// +/// MCP subscription and referral tools. get_referral_code is a mutation (it generates and +/// persists a code on demand), so it routes through → +/// with +/// for shared policy evaluation and the +/// AgentAuditLogs trail; the chat tool returns the code as the result target name. The +/// get_subscription_status and get_referral_stats reads stay on MediatR. +/// [McpServerToolType] public class SubscriptionTools( IGenericRepository userRepository, IPayGateService payGate, IMediator mediator, - IOptions frontendSettings) + IOptions frontendSettings, + McpExecutorBridge executorBridge) { [McpServerTool(Name = "get_subscription_status"), Description("Get the user's subscription status, plan, trial info, and AI message usage.")] public async Task GetSubscriptionStatus( @@ -63,13 +71,11 @@ public async Task GetReferralCode( ClaimsPrincipal user, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var result = await mediator.Send(new GetOrCreateReferralCodeCommand(userId), cancellationToken); - - if (result.IsFailure) - return $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "get_referral_code", new { }, confirmationToken: null, cancellationToken); - return $"Referral Code: {result.Value}\nLink: {frontendSettings.Value.BaseUrl}/r/{result.Value}"; + return result.Succeeded + ? $"Referral Code: {result.TargetName}\nLink: {frontendSettings.Value.BaseUrl}/r/{result.TargetName}" + : result.Message; } private static Guid GetUserId(ClaimsPrincipal user) diff --git a/src/Orbit.Api/Mcp/Tools/TagTools.cs b/src/Orbit.Api/Mcp/Tools/TagTools.cs index fe612c58..75477713 100644 --- a/src/Orbit.Api/Mcp/Tools/TagTools.cs +++ b/src/Orbit.Api/Mcp/Tools/TagTools.cs @@ -2,13 +2,21 @@ using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.Tags.Commands; using Orbit.Application.Tags.Queries; namespace Orbit.Api.Mcp.Tools; +/// +/// MCP tag tools. Mutations route through → +/// with +/// for shared policy evaluation and the +/// AgentAuditLogs trail; each forwards a snake_case argument object matching its backing +/// IAiTool schema. assign_tags routes via the chat tool's id path (forwarding +/// tag_ids), preserving the MCP id-based external contract. The list_tags read stays +/// on MediatR. +/// [McpServerToolType] -public class TagTools(IMediator mediator) +public class TagTools(IMediator mediator, McpExecutorBridge executorBridge) { [McpServerTool(Name = "list_tags"), Description("List all tags for the authenticated user.")] public async Task ListTags( @@ -37,13 +45,13 @@ public async Task CreateTag( [Description("Hex color code, e.g. #FF5733")] string color, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new CreateTagCommand(userId, name, color); - var result = await mediator.Send(command, cancellationToken); + var result = await executorBridge.ExecuteAsync(user, "create_tag", new + { + name, + color + }, confirmationToken: null, cancellationToken); - return result.IsSuccess - ? $"Created tag '{name}' (id: {result.Value})" - : $"Error: {result.Error}"; + return result.Succeeded ? $"Created tag '{name}' (id: {result.TargetId})" : result.Message; } [McpServerTool(Name = "update_tag"), Description("Update a tag's name and/or color.")] @@ -54,26 +62,29 @@ public async Task UpdateTag( [Description("New hex color code, e.g. #FF5733")] string color, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new UpdateTagCommand(userId, McpInputParser.ParseGuid(tagId, "tagId"), name, color); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Updated tag {tagId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "update_tag", new + { + tag_id = tagId, + name, + color + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Updated tag {tagId}" : result.Message; } [McpServerTool(Name = "delete_tag"), Description("Delete a tag by ID.")] public async Task DeleteTag( ClaimsPrincipal user, [Description("The tag ID (GUID)")] string tagId, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: deleting a tag is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new DeleteTagCommand(userId, McpInputParser.ParseGuid(tagId, "tagId")); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Deleted tag {tagId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "delete_tag", new + { + tag_id = tagId + }, confirmationToken, cancellationToken); + + return result.Succeeded ? $"Deleted tag {tagId}" : result.Message; } [McpServerTool(Name = "assign_tags"), Description("Assign tags to a habit. Pass the full list of tag IDs (replaces existing tags on the habit).")] @@ -83,16 +94,19 @@ public async Task AssignTags( [Description("Comma-separated tag IDs (GUIDs). Pass empty string to remove all tags.")] string tagIds, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); var ids = string.IsNullOrWhiteSpace(tagIds) ? new List() : tagIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => McpInputParser.ParseGuid(s, "tagIds")).ToList(); - var command = new AssignTagsCommand(userId, McpInputParser.ParseGuid(habitId, "habitId"), ids); - var result = await mediator.Send(command, cancellationToken); - if (!result.IsSuccess) - return $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "assign_tags", new + { + habit_id = habitId, + tag_ids = ids.Select(i => i.ToString()) + }, confirmationToken: null, cancellationToken); + + if (!result.Succeeded) + return result.Message; return ids.Count > 0 ? $"Assigned {ids.Count} tags to habit {habitId}" diff --git a/src/Orbit.Api/Mcp/Tools/UserFactTools.cs b/src/Orbit.Api/Mcp/Tools/UserFactTools.cs index 65c16d99..585ece4e 100644 --- a/src/Orbit.Api/Mcp/Tools/UserFactTools.cs +++ b/src/Orbit.Api/Mcp/Tools/UserFactTools.cs @@ -2,13 +2,20 @@ using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.UserFacts.Commands; using Orbit.Application.UserFacts.Queries; namespace Orbit.Api.Mcp.Tools; +/// +/// MCP user-fact tools. delete_user_fact routes through → +/// with +/// for shared policy evaluation and the +/// AgentAuditLogs trail, mapping to the plural delete_user_facts chat tool. Because +/// that capability is destructive, the method accepts and forwards a confirmation token. The +/// get_user_facts read stays on MediatR. +/// [McpServerToolType] -public class UserFactTools(IMediator mediator) +public class UserFactTools(IMediator mediator, McpExecutorBridge executorBridge) { [McpServerTool(Name = "get_user_facts"), Description("Get all AI-learned facts about the user.")] public async Task GetUserFacts( @@ -38,14 +45,15 @@ public async Task GetUserFacts( public async Task DeleteUserFact( ClaimsPrincipal user, [Description("The user fact ID (GUID)")] string factId, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: deleting a fact is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new DeleteUserFactCommand(userId, McpInputParser.ParseGuid(factId, "factId")); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Deleted user fact {factId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "delete_user_facts", new + { + fact_id = factId + }, confirmationToken, cancellationToken); + + return result.Succeeded ? $"Deleted user fact {factId}" : result.Message; } private static Guid GetUserId(ClaimsPrincipal user) diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 4e7e2d17..2a61e248 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -263,14 +263,11 @@ public async Task> Handle( var toolResults = new List(); - // Sort tool calls so parent habits are created before sub-habits - var orderedCalls = aiResponse.ToolCalls!.OrderBy(c => c.Name switch - { - "create_habit" => 0, - "create_sub_habit" => 1, - "assign_tags" => 2, - _ => 1 - }).ToList(); + // Sort tool calls by each tool's declared Order (parents before sub-habits, then tags); + // unordered tools default to int.MaxValue and run last, ties broken by stable OrderBy. + var orderedCalls = aiResponse.ToolCalls! + .OrderBy(c => ai.ToolRegistry.GetTool(c.Name)?.Order ?? int.MaxValue) + .ToList(); foreach (var call in orderedCalls) { diff --git a/src/Orbit.Application/Chat/Tools/IAiTool.cs b/src/Orbit.Application/Chat/Tools/IAiTool.cs index 1b8beeff..6df150ea 100644 --- a/src/Orbit.Application/Chat/Tools/IAiTool.cs +++ b/src/Orbit.Application/Chat/Tools/IAiTool.cs @@ -7,6 +7,7 @@ public interface IAiTool string Name { get; } string Description { get; } bool IsReadOnly => false; + int Order => int.MaxValue; object GetParameterSchema(); Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct); } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs index 80aa772d..917c3e4a 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs @@ -13,7 +13,9 @@ public class AssignTagsTool( public string Name => "assign_tags"; public string Description => - "Assign tags to a habit by name. Existing tags with matching names will be reused. New tag names will be auto-created. Only use when the user explicitly asks to tag a habit. WARNING: This REPLACES all existing tags. To add tags, include the existing tags in the list."; + "Assign tags to a habit, replacing all existing tags. Provide either tag_names (existing names are reused, new names are auto-created) OR tag_ids (existing tag IDs, no auto-create). Only use when the user explicitly asks to tag a habit. WARNING: This REPLACES all existing tags. To add tags, include the existing tags in the list."; + + public int Order => 2; public object GetParameterSchema() => new { @@ -24,11 +26,17 @@ public class AssignTagsTool( tag_names = new { type = JsonSchemaTypes.Array, - description = "Tag names to assign", + description = "Tag names to assign. Existing names are reused; new names are auto-created. Provide either tag_names OR tag_ids.", + items = new { type = JsonSchemaTypes.String } + }, + tag_ids = new + { + type = JsonSchemaTypes.Array, + description = "Tag IDs (GUIDs). Provide either tag_ids OR tag_names. An empty array removes all tags.", items = new { type = JsonSchemaTypes.String } } }, - required = new[] { "habit_id", "tag_names" } + required = new[] { "habit_id" } }; public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) @@ -37,9 +45,32 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel !Guid.TryParse(habitIdEl.GetString(), out var habitId)) return new ToolResult(false, Error: "habit_id is required and must be a valid GUID."); - if (!args.TryGetProperty("tag_names", out var tagNamesEl) || tagNamesEl.ValueKind != JsonValueKind.Array) - return new ToolResult(false, Error: "tag_names is required and must be an array."); + if (JsonArgumentParser.PropertyExists(args, "tag_ids")) + return await AssignByIdsAsync(habitId, args, userId, ct); + + if (args.TryGetProperty("tag_names", out var tagNamesEl) && tagNamesEl.ValueKind == JsonValueKind.Array) + return await AssignByNamesAsync(habitId, tagNamesEl, userId, ct); + + return new ToolResult(false, Error: "Provide either tag_ids or tag_names."); + } + + private async Task AssignByIdsAsync(Guid habitId, JsonElement args, Guid userId, CancellationToken ct) + { + var idList = JsonArgumentParser.ParseGuidArray(args, "tag_ids") ?? new List(); + + var habit = await LoadHabitAsync(habitId, userId, ct); + if (habit is null) + return new ToolResult(false, Error: $"Habit {habitId} not found."); + + var resolvedTags = idList.Count == 0 + ? new List() + : (await tagRepository.FindTrackedAsync(t => idList.Contains(t.Id) && t.UserId == userId, ct)).ToList(); + return await ReplaceTagsAsync(habit, resolvedTags, ct); + } + + private async Task AssignByNamesAsync(Guid habitId, JsonElement tagNamesEl, Guid userId, CancellationToken ct) + { var tagNames = new List(); foreach (var t in tagNamesEl.EnumerateArray()) { @@ -51,17 +82,22 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (tagNames.Count == 0) return new ToolResult(false, Error: "At least one tag name is required."); - var habit = await habitRepository.FindOneTrackedAsync( - h => h.Id == habitId && h.UserId == userId, - q => q.Include(h => h.Tags), - ct); - + var habit = await LoadHabitAsync(habitId, userId, ct); if (habit is null) return new ToolResult(false, Error: $"Habit {habitId} not found."); var resolvedTags = await ResolveTagsByNameAsync(tagNames, userId, ct); + return await ReplaceTagsAsync(habit, resolvedTags, ct); + } + + private Task LoadHabitAsync(Guid habitId, Guid userId, CancellationToken ct) => + habitRepository.FindOneTrackedAsync( + h => h.Id == habitId && h.UserId == userId, + q => q.Include(h => h.Tags), + ct); - // Clear existing and assign new + private async Task ReplaceTagsAsync(Habit habit, List resolvedTags, CancellationToken ct) + { foreach (var existing in habit.Tags.ToList()) habit.RemoveTag(existing); foreach (var tag in resolvedTags) diff --git a/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs index 3e526186..37b600c2 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs @@ -20,6 +20,8 @@ public class CreateHabitTool( public string Name => "create_habit"; + public int Order => 0; + public string Description => "Create a new habit or one-time task. Include a relevant emoji when the activity clearly suggests one, or the exact emoji requested by the user. For recurring habits, set frequency_unit and optionally days. For one-time tasks, omit frequency_unit ONLY when the user explicitly described it as a one-time task (e.g., 'just once', 'this Friday only', 'one-time', 'uma vez', 'apenas uma vez'). If the user called it a habit/rotina/hábito or did not state a frequency, ASK FIRST via a NeedsClarification clarification card instead of guessing — the tool will refuse to silently create a one-time task in that case. Structure: use checklist_items for atomic sub-steps done together in one execution (shopping lists, prep lists, packing lists); use sub_habits for sub-activities that need independent tracking, streaks, or different schedules. Never list items only in the description when checklist_items would preserve them. Frequency: when user says 'X times per week' without specifying days, set is_flexible=true, frequency_unit='Week', frequency_quantity=X. When user specifies exact days, use frequency_unit='Day', frequency_quantity=1, days=[specified days]. Example: '3x per week' (no days) = flexible Week/3. '3x per week on Mon/Wed/Fri' = Day/1/[Mon,Wed,Fri]."; diff --git a/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs index 1a191888..d996fc5b 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs @@ -11,6 +11,8 @@ public class CreateSubHabitTool( { public string Name => "create_sub_habit"; + public int Order => 1; + public string Description => "Create a sub-habit under an existing parent habit. Include a relevant emoji when the activity clearly suggests one, or the exact emoji requested by the user."; diff --git a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs index 3b5ef23d..91c1334a 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs @@ -5,6 +5,7 @@ using Orbit.Application.ApiKeys.Queries; using Orbit.Application.Chat.Tools; using Orbit.Application.Gamification.Queries; +using Orbit.Application.Referrals.Commands; using Orbit.Application.Referrals.Queries; using Orbit.Application.Subscriptions.Commands; using Orbit.Application.Subscriptions.Queries; @@ -87,6 +88,26 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel } } +public class GetReferralCodeTool(IMediator mediator) : IAiTool +{ + public string Name => "get_referral_code"; + public string Description => "Get or create the user's referral code (generates one if absent)."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new { } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new GetOrCreateReferralCodeCommand(userId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: result.Value, Payload: new { code = result.Value }) + : new ToolResult(false, Error: result.Error); + } +} + public class GetSubscriptionOverviewTool(IMediator mediator) : IAiTool { public string Name => "get_subscription_overview"; diff --git a/src/Orbit.Application/Chat/Tools/Implementations/ReorderGoalsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/ReorderGoalsTool.cs new file mode 100644 index 00000000..62d6dfba --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/ReorderGoalsTool.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Goals.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class ReorderGoalsTool( + IMediator mediator) : IAiTool +{ + public string Name => "reorder_goals"; + + public string Description => + "Set new display positions for goals. Pass each goal ID with its target zero-based position."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + positions = new + { + type = JsonSchemaTypes.Array, + description = "Goal positions to apply.", + items = new + { + type = JsonSchemaTypes.Object, + properties = new + { + goal_id = new { type = JsonSchemaTypes.String, description = "ID of the goal to position" }, + position = new { type = JsonSchemaTypes.Integer, description = "Zero-based target position" } + }, + required = new[] { "goal_id", "position" } + } + } + }, + required = new[] { "positions" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("positions", out var positionsEl) || positionsEl.ValueKind != JsonValueKind.Array) + return new ToolResult(false, Error: "positions is required and must be an array."); + + var positions = new List(); + foreach (var item in positionsEl.EnumerateArray()) + { + var goalIdValue = JsonArgumentParser.GetOptionalString(item, "goal_id"); + var position = JsonArgumentParser.GetOptionalInt(item, "position"); + if (goalIdValue is null || position is null || !Guid.TryParse(goalIdValue, out var goalId)) + return new ToolResult(false, Error: "Each position requires a valid goal_id GUID and an integer position."); + + positions.Add(new GoalPositionUpdate(goalId, position.Value)); + } + + var result = await mediator.Send(new ReorderGoalsCommand(userId, positions), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + return new ToolResult(true, EntityName: $"{positions.Count} goals"); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/TagTools.cs b/src/Orbit.Application/Chat/Tools/Implementations/TagTools.cs new file mode 100644 index 00000000..024ce182 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/TagTools.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Tags.Commands; +using Orbit.Application.Tags.Queries; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class ListTagsTool(IMediator mediator) : IAiTool +{ + public string Name => "list_tags"; + public string Description => "List the user's tags with their colors."; + public bool IsReadOnly => true; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new { } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new GetTagsQuery(userId), ct); + return result.IsSuccess + ? new ToolResult(true, Payload: result.Value) + : new ToolResult(false, Error: result.Error); + } +} + +public class CreateTagTool(IMediator mediator) : IAiTool +{ + public string Name => "create_tag"; + public string Description => "Create a new tag with a name and hex color."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + name = new { type = JsonSchemaTypes.String, description = "Tag name" }, + color = new { type = JsonSchemaTypes.String, description = "Hex color code, e.g. #FF5733" } + }, + required = new[] { "name", "color" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var name = JsonArgumentParser.GetOptionalString(args, "name"); + var color = JsonArgumentParser.GetOptionalString(args, "color"); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(color)) + return new ToolResult(false, Error: "name and color are required."); + + var result = await mediator.Send(new CreateTagCommand(userId, name, color), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: result.Value.ToString(), EntityName: name) + : new ToolResult(false, Error: result.Error); + } +} + +public class UpdateTagTool(IMediator mediator) : IAiTool +{ + public string Name => "update_tag"; + public string Description => "Update a tag's name and color."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + tag_id = new { type = JsonSchemaTypes.String, description = "ID of the tag to update" }, + name = new { type = JsonSchemaTypes.String, description = "New tag name" }, + color = new { type = JsonSchemaTypes.String, description = "New hex color code, e.g. #FF5733" } + }, + required = new[] { "tag_id", "name", "color" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var tagIdValue = JsonArgumentParser.GetOptionalString(args, "tag_id"); + if (!Guid.TryParse(tagIdValue, out var tagId)) + return new ToolResult(false, Error: "tag_id is required and must be a valid GUID."); + + var name = JsonArgumentParser.GetOptionalString(args, "name"); + var color = JsonArgumentParser.GetOptionalString(args, "color"); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(color)) + return new ToolResult(false, Error: "name and color are required."); + + var result = await mediator.Send(new UpdateTagCommand(userId, tagId, name, color), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: tagId.ToString(), EntityName: name) + : new ToolResult(false, Error: result.Error); + } +} + +public class DeleteTagTool(IMediator mediator) : IAiTool +{ + public string Name => "delete_tag"; + public string Description => "Delete a tag. Use only when the user clearly wants a tag removed."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + tag_id = new { type = JsonSchemaTypes.String, description = "ID of the tag to delete" } + }, + required = new[] { "tag_id" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var tagIdValue = JsonArgumentParser.GetOptionalString(args, "tag_id"); + if (!Guid.TryParse(tagIdValue, out var tagId)) + return new ToolResult(false, Error: "tag_id is required and must be a valid GUID."); + + var result = await mediator.Send(new DeleteTagCommand(userId, tagId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: tagId.ToString(), EntityName: "Deleted tag") + : new ToolResult(false, Error: result.Error); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/UpdateGoalProgressTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/UpdateGoalProgressTool.cs index 53ecf005..9d693302 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/UpdateGoalProgressTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/UpdateGoalProgressTool.cs @@ -10,35 +10,30 @@ public class UpdateGoalProgressTool( IUnitOfWork unitOfWork) : IAiTool { public string Name => "update_goal_progress"; - public string Description => "Update progress on an existing goal. Finds the goal by fuzzy title match and sets the new current value."; + public string Description => "Update progress on an existing goal. Identify the goal by goal_id, or by fuzzy goal_name match, then set the new current value."; public object GetParameterSchema() => new { type = JsonSchemaTypes.Object, properties = new { - goal_name = new { type = JsonSchemaTypes.String, description = "Name or partial name of the goal to update" }, + goal_id = new { type = JsonSchemaTypes.String, description = "ID of the goal to update. Provide either goal_id OR goal_name." }, + goal_name = new { type = JsonSchemaTypes.String, description = "Name or partial name of the goal to update. Provide either goal_id OR goal_name." }, current_value = new { type = "number", description = "New current progress value" }, note = new { type = JsonSchemaTypes.String, description = "Optional note about this progress update" } }, - required = new[] { "goal_name", "current_value" } + required = new[] { "current_value" } }; public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) { - if (!args.TryGetProperty("goal_name", out var nameEl) || string.IsNullOrWhiteSpace(nameEl.GetString())) - return new ToolResult(false, Error: "goal_name is required."); if (!args.TryGetProperty("current_value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Number) return new ToolResult(false, Error: "current_value is required and must be a number."); - var goalName = nameEl.GetString() ?? string.Empty; string? note = args.TryGetProperty("note", out var noteEl) && noteEl.ValueKind == JsonValueKind.String ? noteEl.GetString() : null; - var goals = await goalRepository.FindTrackedAsync(g => g.UserId == userId && g.Status == Domain.Enums.GoalStatus.Active, ct); - var goal = goals.FirstOrDefault(g => g.Title.Equals(goalName, StringComparison.OrdinalIgnoreCase)) - ?? goals.FirstOrDefault(g => g.Title.Contains(goalName, StringComparison.OrdinalIgnoreCase)); - - if (goal is null) return new ToolResult(false, Error: "No active goal found matching '" + goalName + "'."); + var (goal, error) = await ResolveGoalAsync(args, userId, ct); + if (goal is null) return new ToolResult(false, Error: error); var previousValue = goal.CurrentValue; var progressLog = GoalProgressLog.Create(goal.Id, previousValue, valueEl.GetDecimal(), note); @@ -50,4 +45,24 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel await unitOfWork.SaveChangesAsync(ct); return new ToolResult(true, EntityId: goal.Id.ToString(), EntityName: goal.Title); } + + private async Task<(Goal? Goal, string? Error)> ResolveGoalAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (args.TryGetProperty("goal_id", out var idEl) && Guid.TryParse(idEl.GetString(), out var goalId)) + { + var byId = await goalRepository.FindOneTrackedAsync( + g => g.Id == goalId && g.UserId == userId, cancellationToken: ct); + return byId is null ? (null, $"Goal {goalId} not found.") : (byId, null); + } + + if (!args.TryGetProperty("goal_name", out var nameEl) || string.IsNullOrWhiteSpace(nameEl.GetString())) + return (null, "Provide either goal_id or goal_name."); + + var goalName = nameEl.GetString() ?? string.Empty; + var goals = await goalRepository.FindTrackedAsync(g => g.UserId == userId && g.Status == Domain.Enums.GoalStatus.Active, ct); + var goal = goals.FirstOrDefault(g => g.Title.Equals(goalName, StringComparison.OrdinalIgnoreCase)) + ?? goals.FirstOrDefault(g => g.Title.Contains(goalName, StringComparison.OrdinalIgnoreCase)); + + return goal is null ? (null, $"No active goal found matching '{goalName}'.") : (goal, null); + } } diff --git a/src/Orbit.Application/Chat/Tools/README.md b/src/Orbit.Application/Chat/Tools/README.md new file mode 100644 index 00000000..6d3b8428 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/README.md @@ -0,0 +1,62 @@ +# Chat AI Tools + +This directory holds the chat agent's tool layer. Each tool is a small adapter that +exposes one capability to the AI through a JSON-schema'd contract and delegates to the +existing application logic (usually a MediatR command/query). + +## The `IAiTool` contract + +A tool implements `IAiTool` (`IAiTool.cs`): + +- `string Name` — the stable tool name the model calls. It is also the agent + operation id, so it must be unique across all tools. +- `string Description` — what the tool does and when to use it. +- `bool IsReadOnly` (default `false`) — read-only tools never mutate state; this drives + the `IsAgentExecutable`/audit semantics in the catalog. +- `int Order` (default `int.MaxValue`) — execution priority within a single tool-calling + iteration (see below). +- `object GetParameterSchema()` — the JSON schema for the tool's arguments. Build it from + the `JsonSchemaTypes` constants to avoid duplicated string literals. +- `Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct)` — + validate arguments at the boundary, run the work, and return a `ToolResult` + (`Success`, `EntityId`, `EntityName`, `Error`, `Payload`). + +Argument parsing helpers live in `JsonArgumentParser` (same assembly, internal). + +## Tool ordering (`Order`) + +When the model requests several tool calls in one iteration, they are executed in +ascending `Order` (`ProcessUserChatCommand.ProcessToolCallsAsync`). The order is data on +the tool, not a hardcoded switch: + +``` +calls.OrderBy(c => registry.GetTool(c.Name)?.Order ?? int.MaxValue) +``` + +Reserved values: + +| Order | Tool | Why | +| ----- | ---------------- | ----------------------------------------------------- | +| 0 | `create_habit` | Parent habits must exist before sub-habits/tags. | +| 1 | `create_sub_habit` | Sub-habits depend on their parent. | +| 2 | `assign_tags` | Tags attach to a habit that must already exist. | + +Every other tool keeps the default `int.MaxValue` and runs last. `OrderBy` is stable, so +tools that share an `Order` keep their original call order. + +## Catalog mapping is mandatory + +Every registered `IAiTool` MUST have a matching `chatTools` entry on an agent capability +in `AgentCatalogService`. At startup `BuildOperations` enumerates all registered tools and +throws `InvalidOperationException` if any tool name is not mapped to a capability. So a new +tool is always added together with its catalog `chatTools` entry and its DI registration in +`ServiceCollectionExtensions`. + +## MCP routes through the same tools + +The MCP server's mutating tools (`Orbit.Api/Mcp/Tools/*`) do not call MediatR directly. +They forward to `McpExecutorBridge` → `IAgentOperationExecutor`, which resolves the backing +`IAiTool` by name and the matching catalog operation, runs the same policy evaluation +(read-only-credential denial, ownership pre-check, confirmation gating) and writes an +`AgentAuditLogs` row. Chat and MCP therefore share one implementation and one policy surface +per capability; read-only MCP tools stay on MediatR. diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index 8e9917c5..eae19c53 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -269,6 +269,7 @@ public static class AgentCapabilityIds public const string UserFactsRead = "user-facts.read"; public const string UserFactsDelete = "user-facts.delete"; public const string ReferralsRead = "referrals.read"; + public const string ReferralsWrite = "referrals.write"; public const string SubscriptionsRead = "subscriptions.read"; public const string SubscriptionsManage = "subscriptions.manage"; public const string ApiKeysRead = "api-keys.read"; @@ -309,6 +310,7 @@ public static class AgentScopes public const string ReadUserFacts = "read_user_facts"; public const string DeleteUserFacts = "delete_user_facts"; public const string ReadReferrals = "read_referrals"; + public const string WriteReferrals = "write_referrals"; public const string ReadSubscriptions = "read_subscriptions"; public const string ManageSubscriptions = "manage_subscriptions"; public const string ReadApiKeys = "read_api_keys"; @@ -347,6 +349,7 @@ public static class AgentScopes ReadUserFacts, DeleteUserFacts, ReadReferrals, + WriteReferrals, ReadSubscriptions ]; } diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index bdcfcfb3..fff341c6 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -621,7 +621,7 @@ private static IReadOnlyList BuildCapabilities() AgentConfirmationRequirement.None, planRequirement: "Pro", featureFlagKeys: ["goal_tracking"], - chatTools: ["create_goal", "update_goal", "update_goal_status", "update_goal_progress", "link_habits_to_goal"], + chatTools: ["create_goal", "update_goal", "update_goal_status", "update_goal_progress", "link_habits_to_goal", "reorder_goals"], mcpTools: ["create_goal", "update_goal", "update_goal_progress", "update_goal_status", "reorder_goals", "link_habits_to_goal"], controllerActions: [ @@ -659,6 +659,7 @@ private static IReadOnlyList BuildCapabilities() isMutation: false, isPhaseOneReadOnly: false, AgentConfirmationRequirement.None, + chatTools: ["list_tags"], mcpTools: ["list_tags"], controllerActions: ["TagsController.GetTags"]), @@ -672,7 +673,7 @@ private static IReadOnlyList BuildCapabilities() isMutation: true, isPhaseOneReadOnly: false, AgentConfirmationRequirement.None, - chatTools: ["assign_tags"], + chatTools: ["assign_tags", "create_tag", "update_tag"], mcpTools: ["create_tag", "update_tag", "assign_tags"], controllerActions: ["TagsController.CreateTag", "TagsController.UpdateTag", "TagsController.AssignTags"]), @@ -686,6 +687,7 @@ private static IReadOnlyList BuildCapabilities() isMutation: true, isPhaseOneReadOnly: false, AgentConfirmationRequirement.FreshConfirmation, + chatTools: ["delete_tag"], mcpTools: ["delete_tag"], controllerActions: ["TagsController.DeleteTag"]), @@ -950,14 +952,27 @@ private static IReadOnlyList BuildCapabilities() isPhaseOneReadOnly: false, AgentConfirmationRequirement.None, chatTools: ["get_referral_overview"], - mcpTools: ["get_referral_stats", "get_referral_code"], + mcpTools: ["get_referral_stats"], controllerActions: [ - "ReferralController.GetOrCreateCode", "ReferralController.GetStats", "ReferralController.GetDashboard" ]), + CreateCapability( + AgentCapabilityIds.ReferralsWrite, + "Write Referrals", + "Generates the user's referral code on demand.", + "referrals", + AgentScopes.WriteReferrals, + AgentRiskClass.Low, + isMutation: true, + isPhaseOneReadOnly: false, + AgentConfirmationRequirement.None, + chatTools: ["get_referral_code"], + mcpTools: ["get_referral_code"], + controllerActions: ["ReferralController.GetOrCreateCode"]), + CreateCapability( AgentCapabilityIds.SubscriptionsRead, "Read Subscription State", diff --git a/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs index 862cf302..d7f8a567 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs @@ -103,13 +103,53 @@ public async Task MissingHabitId_ReturnsError() } [Fact] - public async Task MissingTagNames_ReturnsError() + public async Task NeitherTagIdsNorTagNames_ReturnsError() { var id = Guid.NewGuid(); var result = await Execute($$$"""{"habit_id": "{{{id}}}"}"""); result.Success.Should().BeFalse(); - result.Error.Should().Contain("tag_names is required"); + result.Error.Should().Contain("Provide either tag_ids or tag_names"); + } + + [Fact] + public async Task AssignByTagIds_ReplacesExistingTags_NoAutoCreate() + { + var habit = CreateHabit("Run"); + var oldTag = Tag.Create(UserId, "Old", "#000000").Value; + habit.AddTag(oldTag); + SetupHabitFound(habit); + + var health = Tag.Create(UserId, "Health", "#ff0000").Value; + var fitness = Tag.Create(UserId, "Fitness", "#00ff00").Value; + _tagRepo.FindTrackedAsync( + Arg.Any>>(), + Arg.Any() + ).Returns(new List { health, fitness }.AsReadOnly()); + + var result = await Execute($$$"""{"habit_id": "{{{habit.Id}}}", "tag_ids": ["{{{health.Id}}}", "{{{fitness.Id}}}"]}"""); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Be("Run"); + habit.Tags.Should().BeEquivalentTo(new[] { health, fitness }); + await _tagRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task AssignByEmptyTagIds_RemovesAllTags() + { + var habit = CreateHabit("Run"); + habit.AddTag(Tag.Create(UserId, "Old", "#000000").Value); + SetupHabitFound(habit); + + var result = await Execute($$$"""{"habit_id": "{{{habit.Id}}}", "tag_ids": []}"""); + + result.Success.Should().BeTrue(); + habit.Tags.Should().BeEmpty(); + await _tagRepo.DidNotReceive().FindTrackedAsync( + Arg.Any>>(), Arg.Any()); + await _tagRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } [Fact] diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs index e93fd385..aaf942b8 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs @@ -46,8 +46,21 @@ public void ToolMetadata_ExposesExpectedNamesDescriptionsAndSchemas() var updateGoalStatusTool = new UpdateGoalStatusTool(Repo(), gamificationService, unitOfWork, logger); var updateGoalTool = new UpdateGoalTool(Repo(), unitOfWork); var updateHabitTool = new UpdateHabitTool(Repo()); + var listTagsTool = new ListTagsTool(mediator); + var createTagTool = new CreateTagTool(mediator); + var updateTagTool = new UpdateTagTool(mediator); + var deleteTagTool = new DeleteTagTool(mediator); + var reorderGoalsTool = new ReorderGoalsTool(mediator); + var getReferralCodeTool = new GetReferralCodeTool(mediator); AssertTool(assignTagsTool, "assign_tags", "tag", "tag_names"); + JsonSerializer.Serialize(assignTagsTool.GetParameterSchema()).Should().Contain("tag_ids"); + AssertTool(listTagsTool, "list_tags", "tag", "type", expectReadOnly: true); + AssertTool(createTagTool, "create_tag", "tag", "color"); + AssertTool(updateTagTool, "update_tag", "tag", "tag_id"); + AssertTool(deleteTagTool, "delete_tag", "tag", "tag_id"); + AssertTool(reorderGoalsTool, "reorder_goals", "position", "goal_id"); + AssertTool(getReferralCodeTool, "get_referral_code", "referral", "type"); AssertTool(bulkUpdateHabitEmojisTool, "bulk_update_habit_emojis", "emojis", "infer_from_title"); AssertTool(bulkLogHabitsTool, "bulk_log_habits", "multiple", "habit_ids"); AssertTool(bulkSkipHabitsTool, "bulk_skip_habits", "multiple", "habit_ids"); diff --git a/tests/Orbit.Application.Tests/Chat/Tools/TagToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/TagToolTests.cs new file mode 100644 index 00000000..57f13afe --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/TagToolTests.cs @@ -0,0 +1,220 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using NSubstitute; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Goals.Commands; +using Orbit.Application.Referrals.Commands; +using Orbit.Application.Tags.Commands; +using Orbit.Application.Tags.Queries; +using Orbit.Domain.Common; + +namespace Orbit.Application.Tests.Chat.Tools; + +public class TagToolTests +{ + private readonly IMediator _mediator = Substitute.For(); + private static readonly Guid UserId = Guid.NewGuid(); + + private static JsonElement Args(string json) => JsonDocument.Parse(json).RootElement; + + // --- ListTagsTool --- + + [Fact] + public async Task ListTags_Success_ReturnsPayload() + { + IReadOnlyList tags = [new TagResponse(Guid.NewGuid(), "Health", "#fff")]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(tags)); + + var result = await new ListTagsTool(_mediator).ExecuteAsync(Args("{}"), UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Payload.Should().BeSameAs(tags); + } + + [Fact] + public async Task ListTags_Failure_ReturnsError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure>("boom")); + + var result = await new ListTagsTool(_mediator).ExecuteAsync(Args("{}"), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("boom"); + } + + // --- CreateTagTool --- + + [Fact] + public async Task CreateTag_Success_ReturnsIdAndName() + { + var tagId = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(tagId)); + + var result = await new CreateTagTool(_mediator) + .ExecuteAsync(Args("""{"name":"Health","color":"#FF0000"}"""), UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(tagId.ToString()); + result.EntityName.Should().Be("Health"); + } + + [Fact] + public async Task CreateTag_Failure_ReturnsError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("duplicate")); + + var result = await new CreateTagTool(_mediator) + .ExecuteAsync(Args("""{"name":"Health","color":"#FF0000"}"""), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("duplicate"); + } + + [Fact] + public async Task CreateTag_MissingColor_ReturnsErrorWithoutCallingMediator() + { + var result = await new CreateTagTool(_mediator) + .ExecuteAsync(Args("""{"name":"Health"}"""), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + // --- UpdateTagTool --- + + [Fact] + public async Task UpdateTag_Success_ReturnsIdAndName() + { + var tagId = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var result = await new UpdateTagTool(_mediator) + .ExecuteAsync(Args($$"""{"tag_id":"{{tagId}}","name":"Renamed","color":"#00FF00"}"""), UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(tagId.ToString()); + result.EntityName.Should().Be("Renamed"); + } + + [Fact] + public async Task UpdateTag_InvalidId_ReturnsErrorWithoutCallingMediator() + { + var result = await new UpdateTagTool(_mediator) + .ExecuteAsync(Args("""{"tag_id":"not-a-guid","name":"X","color":"#000"}"""), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("tag_id"); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + // --- DeleteTagTool --- + + [Fact] + public async Task DeleteTag_Success_ReturnsId() + { + var tagId = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var result = await new DeleteTagTool(_mediator) + .ExecuteAsync(Args($$"""{"tag_id":"{{tagId}}"}"""), UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(tagId.ToString()); + } + + [Fact] + public async Task DeleteTag_MissingId_ReturnsErrorWithoutCallingMediator() + { + var result = await new DeleteTagTool(_mediator) + .ExecuteAsync(Args("{}"), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + // --- ReorderGoalsTool --- + + [Fact] + public async Task ReorderGoals_Success_ReturnsCount() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + var goalId = Guid.NewGuid(); + + var result = await new ReorderGoalsTool(_mediator) + .ExecuteAsync(Args($$"""{"positions":[{"goal_id":"{{goalId}}","position":0}]}"""), UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Be("1 goals"); + } + + [Fact] + public async Task ReorderGoals_Failure_ReturnsError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("paygate")); + var goalId = Guid.NewGuid(); + + var result = await new ReorderGoalsTool(_mediator) + .ExecuteAsync(Args($$"""{"positions":[{"goal_id":"{{goalId}}","position":0}]}"""), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("paygate"); + } + + [Fact] + public async Task ReorderGoals_MissingPositions_ReturnsErrorWithoutCallingMediator() + { + var result = await new ReorderGoalsTool(_mediator) + .ExecuteAsync(Args("{}"), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReorderGoals_InvalidGoalId_ReturnsErrorWithoutCallingMediator() + { + var result = await new ReorderGoalsTool(_mediator) + .ExecuteAsync(Args("""{"positions":[{"goal_id":"nope","position":0}]}"""), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + // --- GetReferralCodeTool --- + + [Fact] + public async Task GetReferralCode_Success_ReturnsCode() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success("ABC12345")); + + var result = await new GetReferralCodeTool(_mediator).ExecuteAsync(Args("{}"), UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Be("ABC12345"); + var payload = JsonSerializer.Serialize(result.Payload); + payload.Should().Contain("ABC12345"); + } + + [Fact] + public async Task GetReferralCode_Failure_ReturnsError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("no user")); + + var result = await new GetReferralCodeTool(_mediator).ExecuteAsync(Args("{}"), UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("no user"); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/UpdateGoalProgressToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/UpdateGoalProgressToolTests.cs index cabb598d..eacfe73e 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/UpdateGoalProgressToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateGoalProgressToolTests.cs @@ -59,13 +59,31 @@ public async Task ExecuteAsync_GoalNotFound_ReturnsError() } [Fact] - public async Task ExecuteAsync_MissingGoalName_ReturnsError() + public async Task ExecuteAsync_MissingGoalIdentifier_ReturnsError() { var args = JsonDocument.Parse("{\"current_value\": 5}").RootElement; var result = await _tool.ExecuteAsync(args, UserId, CancellationToken.None); result.Success.Should().BeFalse(); - result.Error.Should().Contain("goal_name is required"); + result.Error.Should().Contain("Provide either goal_id or goal_name"); + } + + [Fact] + public async Task ExecuteAsync_GoalById_UpdatesProgress() + { + var goal = Goal.Create(UserId, "Lose Weight", 10, "kg").Value; + _goalRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(goal); + + var args = JsonDocument.Parse($"{{\"goal_id\": \"{goal.Id}\", \"current_value\": 5}}").RootElement; + var result = await _tool.ExecuteAsync(args, UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.EntityId.Should().Be(goal.Id.ToString()); + await _progressLogRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index 6d56e339..44745ffa 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -992,6 +992,7 @@ public async Task Handle_ToolCallOrdering_CreateHabitRunsBeforeSubHabit() createTool.Name.Returns("create_habit"); createTool.Description.Returns("Creates"); createTool.IsReadOnly.Returns(false); + createTool.Order.Returns(0); createTool.GetParameterSchema().Returns(new { type = "object" }); createTool.ExecuteAsync(Arg.Any(), UserId, Arg.Any()) .Returns(callInfo => @@ -1004,6 +1005,7 @@ public async Task Handle_ToolCallOrdering_CreateHabitRunsBeforeSubHabit() subTool.Name.Returns("create_sub_habit"); subTool.Description.Returns("Creates sub"); subTool.IsReadOnly.Returns(false); + subTool.Order.Returns(1); subTool.GetParameterSchema().Returns(new { type = "object" }); subTool.ExecuteAsync(Arg.Any(), UserId, Arg.Any()) .Returns(callInfo => @@ -1037,6 +1039,60 @@ public async Task Handle_ToolCallOrdering_CreateHabitRunsBeforeSubHabit() executionOrder.Should().Equal("create_habit", "create_sub_habit"); } + [Fact] + public async Task Handle_ToolCallOrdering_RespectsToolOrderProperty() + { + SetupUserAndPayGate(); + + var executionOrder = new List(); + + var createTool = OrderedTool("create_habit", 0, executionOrder); + var subTool = OrderedTool("create_sub_habit", 1, executionOrder); + var assignTool = OrderedTool("assign_tags", 2, executionOrder); + + var handler = CreateHandler(createTool, subTool, assignTool); + + var toolCallArgs = JsonDocument.Parse("{}").RootElement; + // Fed in reverse to prove ordering is driven by Order, not call order. + var aiResponseWithTools = new AiResponse + { + ToolCalls = + [ + new AiToolCall("assign_tags", "call_3", toolCallArgs), + new AiToolCall("create_sub_habit", "call_2", toolCallArgs), + new AiToolCall("create_habit", "call_1", toolCallArgs) + ], + ConversationContext = TestConversationContext + }; + SetupAiResponse(aiResponseWithTools); + + _aiIntentService.ContinueWithToolResultsAsync( + Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Result.Success(new AiResponse { TextMessage = "Done!", ToolCalls = null })); + + var command = new ProcessUserChatCommand(UserId, "Create parent, child, and tag"); + await handler.Handle(command, CancellationToken.None); + + executionOrder.Should().Equal("create_habit", "create_sub_habit", "assign_tags"); + } + + private static IAiTool OrderedTool(string name, int order, List executionOrder) + { + var tool = Substitute.For(); + tool.Name.Returns(name); + tool.Description.Returns(name); + tool.IsReadOnly.Returns(false); + tool.Order.Returns(order); + tool.GetParameterSchema().Returns(new { type = "object" }); + tool.ExecuteAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(_ => + { + executionOrder.Add(name); + return new ToolResult(true, EntityId: Guid.NewGuid().ToString(), EntityName: name); + }); + return tool; + } + // --- suggest_breakdown with no sub habits in args --- [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/GoalToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/GoalToolsTests.cs index e8ed84e5..b16761bd 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/GoalToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/GoalToolsTests.cs @@ -2,12 +2,13 @@ using FluentAssertions; using MediatR; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; using Orbit.Application.Common; -using Orbit.Application.Goals.Commands; using Orbit.Application.Goals.Queries; using Orbit.Domain.Common; using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Mcp; @@ -15,16 +16,38 @@ namespace Orbit.Infrastructure.Tests.Mcp; public class GoalToolsTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly GoalTools _tools; private readonly ClaimsPrincipal _user; public GoalToolsTests() { - _tools = new GoalTools(_mediator); + _tools = new GoalTools(_mediator, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private void StubExecutor(AgentOperationStatus status, string? targetId = null, string? targetName = null, + string? policyReason = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", "operation", AgentRiskClass.Low, AgentConfirmationRequirement.None, + status, TargetId: targetId, TargetName: targetName, PolicyReason: policyReason)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + + private async Task CapturedRequestAsync(Func act) + { + await act(); + var calls = _executor.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .ToList(); + calls.Should().NotBeEmpty(); + return (AgentExecuteOperationRequest)calls[^1].GetArguments()[0]!; + } + [Fact] public async Task ListGoals_Success_ReturnsFormattedList() { @@ -96,14 +119,15 @@ public async Task GetGoal_Failure_ReturnsError() } [Fact] - public async Task CreateGoal_Success_ReturnsCreatedMessage() + public async Task CreateGoal_Success_RoutesThroughExecutorAndReturnsCreatedMessage() { var newId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(newId)); + StubExecutor(AgentOperationStatus.Succeeded, targetId: newId.ToString(), targetName: "New Goal"); - var result = await _tools.CreateGoal(_user, "New Goal", 100, "km"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.CreateGoal(_user, "New Goal", 100, "km")); + request.OperationId.Should().Be("create_goal"); result.Should().Contain("Created goal 'New Goal'"); result.Should().Contain(newId.ToString()); } @@ -111,8 +135,7 @@ public async Task CreateGoal_Success_ReturnsCreatedMessage() [Fact] public async Task CreateGoal_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Pro required")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Pro required"); var result = await _tools.CreateGoal(_user, "Goal", 100, "km"); @@ -120,22 +143,22 @@ public async Task CreateGoal_Failure_ReturnsError() } [Fact] - public async Task UpdateGoal_Success_ReturnsUpdatedMessage() + public async Task UpdateGoal_Success_RoutesThroughExecutorAndReturnsUpdatedMessage() { var goalId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded, targetId: goalId.ToString()); - var result = await _tools.UpdateGoal(_user, goalId.ToString(), "Updated", 200, "km"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.UpdateGoal(_user, goalId.ToString(), "Updated", 200, "km")); + request.OperationId.Should().Be("update_goal"); result.Should().Contain("Updated goal"); } [Fact] public async Task UpdateGoal_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Goal not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Goal not found"); var result = await _tools.UpdateGoal(_user, Guid.NewGuid().ToString(), "Title", 100, "km"); @@ -143,21 +166,31 @@ public async Task UpdateGoal_Failure_ReturnsError() } [Fact] - public async Task DeleteGoal_Success_ReturnsDeletedMessage() + public async Task DeleteGoal_Success_RoutesThroughExecutorAndReturnsDeletedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.DeleteGoal(_user, Guid.NewGuid().ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.DeleteGoal(_user, Guid.NewGuid().ToString())); + request.OperationId.Should().Be("delete_goal"); result.Should().Contain("Deleted goal"); } + [Fact] + public async Task DeleteGoal_PendingConfirmation_ReturnsConfirmationPrompt() + { + StubExecutor(AgentOperationStatus.PendingConfirmation); + + var result = await _tools.DeleteGoal(_user, Guid.NewGuid().ToString()); + + result.Should().Contain("Confirmation required"); + } + [Fact] public async Task DeleteGoal_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Goal not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Goal not found"); var result = await _tools.DeleteGoal(_user, Guid.NewGuid().ToString()); @@ -165,23 +198,23 @@ public async Task DeleteGoal_Failure_ReturnsError() } [Fact] - public async Task LinkHabitsToGoal_Success_ReturnsLinkedMessage() + public async Task LinkHabitsToGoal_Success_RoutesThroughExecutorAndReturnsLinkedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var goalId = Guid.NewGuid(); var habitId = Guid.NewGuid(); - var result = await _tools.LinkHabitsToGoal(_user, goalId.ToString(), habitId.ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.LinkHabitsToGoal(_user, goalId.ToString(), habitId.ToString())); + request.OperationId.Should().Be("link_habits_to_goal"); result.Should().Contain("Linked 1 habits"); } [Fact] public async Task LinkHabitsToGoal_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); var result = await _tools.LinkHabitsToGoal(_user, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); @@ -189,14 +222,15 @@ public async Task LinkHabitsToGoal_Failure_ReturnsError() } [Fact] - public async Task UpdateGoalProgress_Success_ReturnsUpdatedMessage() + public async Task UpdateGoalProgress_Success_RoutesThroughExecutorAndReturnsUpdatedMessage() { var goalId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded, targetId: goalId.ToString()); - var result = await _tools.UpdateGoalProgress(_user, goalId.ToString(), 50); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.UpdateGoalProgress(_user, goalId.ToString(), 50)); + request.OperationId.Should().Be("update_goal_progress"); result.Should().Contain("Updated progress"); result.Should().Contain("50"); } @@ -204,8 +238,7 @@ public async Task UpdateGoalProgress_Success_ReturnsUpdatedMessage() [Fact] public async Task UpdateGoalProgress_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); var result = await _tools.UpdateGoalProgress(_user, Guid.NewGuid().ToString(), 50); @@ -213,14 +246,15 @@ public async Task UpdateGoalProgress_Failure_ReturnsError() } [Fact] - public async Task UpdateGoalStatus_Success_ReturnsUpdatedMessage() + public async Task UpdateGoalStatus_Success_RoutesThroughExecutorAndReturnsUpdatedMessage() { var goalId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded, targetId: goalId.ToString()); - var result = await _tools.UpdateGoalStatus(_user, goalId.ToString(), "Completed"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.UpdateGoalStatus(_user, goalId.ToString(), "Completed")); + request.OperationId.Should().Be("update_goal_status"); result.Should().Contain("Updated goal"); result.Should().Contain("Completed"); } @@ -228,8 +262,7 @@ public async Task UpdateGoalStatus_Success_ReturnsUpdatedMessage() [Fact] public async Task UpdateGoalStatus_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); var result = await _tools.UpdateGoalStatus(_user, Guid.NewGuid().ToString(), "Completed"); @@ -286,22 +319,22 @@ public async Task GetGoalReview_Failure_ReturnsError() } [Fact] - public async Task ReorderGoals_Success_ReturnsReorderedMessage() + public async Task ReorderGoals_Success_RoutesThroughExecutorAndReturnsReorderedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var json = $"[{{\"id\":\"{Guid.NewGuid()}\",\"position\":0}}]"; - var result = await _tools.ReorderGoals(_user, json); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.ReorderGoals(_user, json)); + request.OperationId.Should().Be("reorder_goals"); result.Should().Contain("Reordered 1 goals"); } [Fact] public async Task ReorderGoals_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Invalid")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Invalid"); var json = "[]"; var result = await _tools.ReorderGoals(_user, json); diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/NotificationToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/NotificationToolsTests.cs index 941ec4f0..2ef3adf3 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/NotificationToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/NotificationToolsTests.cs @@ -2,23 +2,25 @@ using FluentAssertions; using MediatR; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; -using Orbit.Application.Common; -using Orbit.Application.Notifications.Commands; using Orbit.Application.Notifications.Queries; using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Mcp; public class NotificationToolsTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly NotificationTools _tools; private readonly ClaimsPrincipal _user; public NotificationToolsTests() { - _tools = new NotificationTools(_mediator); + _tools = new NotificationTools(_mediator, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } @@ -26,6 +28,26 @@ public NotificationToolsTests() private static NotificationItemDto Item(string title, string body, bool isRead) => new(Guid.NewGuid(), title, body, null, null, isRead, DateTime.UtcNow); + private void StubExecutor(AgentOperationStatus status, string? policyReason = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", "operation", AgentRiskClass.Low, AgentConfirmationRequirement.None, + status, PolicyReason: policyReason)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + + private async Task CapturedRequestAsync(Func act) + { + await act(); + var calls = _executor.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .ToList(); + calls.Should().NotBeEmpty(); + return (AgentExecuteOperationRequest)calls[^1].GetArguments()[0]!; + } + [Fact] public async Task GetNotifications_NoNotifications_ReturnsNoNotificationsMessage() { @@ -82,22 +104,23 @@ public async Task GetNotifications_Failure_ReturnsError() } [Fact] - public async Task MarkNotificationRead_Success_ReturnsMarkedMessage() + public async Task MarkNotificationRead_Success_RoutesThroughExecutorAndReturnsMarkedMessage() { var notificationId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.MarkNotificationRead(_user, notificationId.ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.MarkNotificationRead(_user, notificationId.ToString())); + request.OperationId.Should().Be("update_notifications"); + request.Arguments.GetRawText().Should().Contain("mark_read"); result.Should().Be($"Marked notification {notificationId} as read."); } [Fact] public async Task MarkNotificationRead_NotFound_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure(ErrorMessages.NotificationNotFound, ErrorCodes.NotificationNotFound)); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Notification not found."); var result = await _tools.MarkNotificationRead(_user, Guid.NewGuid().ToString()); @@ -105,38 +128,40 @@ public async Task MarkNotificationRead_NotFound_ReturnsError() } [Fact] - public async Task MarkAllNotificationsRead_ReturnsCountMessage() + public async Task MarkAllNotificationsRead_RoutesThroughExecutorAndReturnsMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(2)); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.MarkAllNotificationsRead(_user); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.MarkAllNotificationsRead(_user)); - result.Should().Be("Marked 2 notifications as read."); + request.OperationId.Should().Be("update_notifications"); + request.Arguments.GetRawText().Should().Contain("mark_all_read"); + result.Should().Be("Marked all notifications as read."); } [Fact] - public async Task DeleteNotification_Success_ReturnsDeletedMessage() + public async Task DeleteNotification_Success_RoutesThroughExecutorAndReturnsDeletedMessage() { var notificationId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.DeleteNotification(_user, notificationId.ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.DeleteNotification(_user, notificationId.ToString())); + request.OperationId.Should().Be("delete_notifications"); + request.Arguments.GetRawText().Should().Contain("delete_one"); result.Should().Be($"Deleted notification {notificationId}."); } [Fact] - public async Task DeleteNotification_NotFound_IsIdempotentAndReturnsDeletedMessage() + public async Task DeleteNotification_PendingConfirmation_ReturnsConfirmationPrompt() { - var notificationId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.PendingConfirmation); - var result = await _tools.DeleteNotification(_user, notificationId.ToString()); + var result = await _tools.DeleteNotification(_user, Guid.NewGuid().ToString()); - result.Should().Be($"Deleted notification {notificationId}."); + result.Should().Contain("Confirmation required"); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs index f11f98e3..f118bcca 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs @@ -2,27 +2,50 @@ using FluentAssertions; using MediatR; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; -using Orbit.Application.Profile.Commands; using Orbit.Application.Profile.Queries; using Orbit.Domain.Common; using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Mcp; public class ProfileToolsTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly ProfileTools _tools; private readonly ClaimsPrincipal _user; public ProfileToolsTests() { - _tools = new ProfileTools(_mediator); + _tools = new ProfileTools(_mediator, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private void StubExecutor(AgentOperationStatus status, string? policyReason = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", "operation", AgentRiskClass.Low, AgentConfirmationRequirement.None, + status, PolicyReason: policyReason)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + + private async Task CapturedRequestAsync(Func act) + { + await act(); + var calls = _executor.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .ToList(); + calls.Should().NotBeEmpty(); + return (AgentExecuteOperationRequest)calls[^1].GetArguments()[0]!; + } + [Fact] public async Task GetProfile_Success_ReturnsFormattedProfile() { @@ -56,21 +79,22 @@ public async Task GetProfile_Failure_ReturnsError() } [Fact] - public async Task SetTimezone_Success_ReturnsConfirmation() + public async Task SetTimezone_Success_RoutesToUpdatePreferencesAndReturnsConfirmation() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.SetTimezone(_user, "America/New_York"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.SetTimezone(_user, "America/New_York")); + request.OperationId.Should().Be("update_profile_preferences"); + request.Arguments.GetRawText().Should().Contain("set_timezone"); result.Should().Contain("Timezone set to America/New_York"); } [Fact] public async Task SetTimezone_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Invalid timezone")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Invalid timezone"); var result = await _tools.SetTimezone(_user, "Invalid/Zone"); @@ -78,21 +102,22 @@ public async Task SetTimezone_Failure_ReturnsError() } [Fact] - public async Task SetLanguage_Success_ReturnsConfirmation() + public async Task SetLanguage_Success_RoutesToUpdatePreferencesAndReturnsConfirmation() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.SetLanguage(_user, "pt-BR"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.SetLanguage(_user, "pt-BR")); + request.OperationId.Should().Be("update_profile_preferences"); + request.Arguments.GetRawText().Should().Contain("set_language"); result.Should().Contain("Language set to pt-BR"); } [Fact] public async Task SetLanguage_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Invalid language")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Invalid language"); var result = await _tools.SetLanguage(_user, "xx"); @@ -100,21 +125,21 @@ public async Task SetLanguage_Failure_ReturnsError() } [Fact] - public async Task SetAiMemory_Enabled_ReturnsEnabledMessage() + public async Task SetAiMemory_Enabled_RoutesThroughExecutorAndReturnsEnabledMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.SetAiMemory(_user, true); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.SetAiMemory(_user, true)); + request.OperationId.Should().Be("set_ai_memory"); result.Should().Be("AI memory enabled"); } [Fact] public async Task SetAiMemory_Disabled_ReturnsDisabledMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var result = await _tools.SetAiMemory(_user, false); @@ -124,8 +149,7 @@ public async Task SetAiMemory_Disabled_ReturnsDisabledMessage() [Fact] public async Task SetAiMemory_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); var result = await _tools.SetAiMemory(_user, true); @@ -133,21 +157,21 @@ public async Task SetAiMemory_Failure_ReturnsError() } [Fact] - public async Task SetAiSummary_Enabled_ReturnsEnabledMessage() + public async Task SetAiSummary_Enabled_RoutesThroughExecutorAndReturnsEnabledMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.SetAiSummary(_user, true); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.SetAiSummary(_user, true)); + request.OperationId.Should().Be("set_ai_summary"); result.Should().Be("AI summary enabled"); } [Fact] public async Task SetAiSummary_Disabled_ReturnsDisabledMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var result = await _tools.SetAiSummary(_user, false); @@ -157,8 +181,7 @@ public async Task SetAiSummary_Disabled_ReturnsDisabledMessage() [Fact] public async Task SetAiSummary_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); var result = await _tools.SetAiSummary(_user, true); @@ -166,21 +189,45 @@ public async Task SetAiSummary_Failure_ReturnsError() } [Fact] - public async Task SetWeekStartDay_Sunday_ReturnsSundayMessage() + public async Task SetColorScheme_Success_RoutesThroughExecutor() + { + StubExecutor(AgentOperationStatus.Succeeded); + + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.SetColorScheme(_user, "blue")); + + request.OperationId.Should().Be("set_color_scheme"); + result.Should().Contain("Color scheme set to blue"); + } + + [Fact] + public async Task SetColorScheme_Null_SendsExplicitNullColorScheme() + { + StubExecutor(AgentOperationStatus.Succeeded); + + var request = await CapturedRequestAsync(async () => await _tools.SetColorScheme(_user, null)); + + request.OperationId.Should().Be("set_color_scheme"); + request.Arguments.GetRawText().Should().Contain("color_scheme"); + } + + [Fact] + public async Task SetWeekStartDay_Sunday_RoutesToUpdatePreferencesAndReturnsSundayMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.SetWeekStartDay(_user, 0); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.SetWeekStartDay(_user, 0)); + request.OperationId.Should().Be("update_profile_preferences"); + request.Arguments.GetRawText().Should().Contain("set_week_start_day"); result.Should().Contain("Sunday"); } [Fact] public async Task SetWeekStartDay_Monday_ReturnsMondayMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var result = await _tools.SetWeekStartDay(_user, 1); @@ -190,8 +237,7 @@ public async Task SetWeekStartDay_Monday_ReturnsMondayMessage() [Fact] public async Task SetWeekStartDay_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Invalid")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Invalid"); var result = await _tools.SetWeekStartDay(_user, 5); diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs index 051b2b7f..82650fa5 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs @@ -3,13 +3,14 @@ using MediatR; using Microsoft.Extensions.Options; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; using Orbit.Application.Common; -using Orbit.Application.Referrals.Commands; using Orbit.Application.Referrals.Queries; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Mcp; @@ -18,6 +19,7 @@ public class SubscriptionToolsTests private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IMediator _mediator = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly SubscriptionTools _tools; private readonly ClaimsPrincipal _user; private readonly Guid _userId = Guid.NewGuid(); @@ -25,12 +27,22 @@ public class SubscriptionToolsTests public SubscriptionToolsTests() { var frontendSettings = Options.Create(new FrontendSettings { BaseUrl = "https://app.useorbit.org" }); - _tools = new SubscriptionTools(_userRepo, _payGate, _mediator, frontendSettings); + _tools = new SubscriptionTools(_userRepo, _payGate, _mediator, frontendSettings, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, _userId.ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private void StubExecutor(AgentOperationStatus status, string? targetName = null, string? policyReason = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", "operation", AgentRiskClass.Low, AgentConfirmationRequirement.None, + status, TargetName: targetName, PolicyReason: policyReason)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + // --- GetSubscriptionStatus --- [Fact] @@ -141,13 +153,16 @@ public async Task GetReferralStats_Failure_ReturnsError() // --- GetReferralCode --- [Fact] - public async Task GetReferralCode_Success_ReturnsCodeAndLink() + public async Task GetReferralCode_Success_RoutesThroughExecutorAndReturnsCodeAndLink() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success("XYZ789")); + StubExecutor(AgentOperationStatus.Succeeded, targetName: "XYZ789"); var result = await _tools.GetReferralCode(_user); + var request = (AgentExecuteOperationRequest)_executor.ReceivedCalls() + .Single(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .GetArguments()[0]!; + request.OperationId.Should().Be("get_referral_code"); result.Should().Contain("Referral Code: XYZ789"); result.Should().Contain("Link: https://app.useorbit.org/r/XYZ789"); } @@ -155,8 +170,7 @@ public async Task GetReferralCode_Success_ReturnsCodeAndLink() [Fact] public async Task GetReferralCode_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("User not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "User not found"); var result = await _tools.GetReferralCode(_user); diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/TagToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/TagToolsTests.cs index d616fb1b..d8d81938 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/TagToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/TagToolsTests.cs @@ -2,26 +2,50 @@ using FluentAssertions; using MediatR; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; -using Orbit.Application.Tags.Commands; using Orbit.Application.Tags.Queries; using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Mcp; public class TagToolsTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly TagTools _tools; private readonly ClaimsPrincipal _user; public TagToolsTests() { - _tools = new TagTools(_mediator); + _tools = new TagTools(_mediator, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private void StubExecutor(AgentOperationStatus status, string? targetId = null, string? targetName = null, + string? policyReason = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", "operation", AgentRiskClass.Low, AgentConfirmationRequirement.None, + status, TargetId: targetId, TargetName: targetName, PolicyReason: policyReason)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + + private async Task CapturedRequestAsync(Func act) + { + await act(); + var calls = _executor.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .ToList(); + calls.Should().NotBeEmpty(); + return (AgentExecuteOperationRequest)calls[^1].GetArguments()[0]!; + } + [Fact] public async Task ListTags_Success_ReturnsFormattedList() { @@ -62,14 +86,15 @@ public async Task ListTags_Failure_ReturnsError() } [Fact] - public async Task CreateTag_Success_ReturnsCreatedMessage() + public async Task CreateTag_Success_RoutesThroughExecutorAndReturnsCreatedMessage() { var newId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(newId)); + StubExecutor(AgentOperationStatus.Succeeded, targetId: newId.ToString(), targetName: "Work"); - var result = await _tools.CreateTag(_user, "Work", "#0000FF"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.CreateTag(_user, "Work", "#0000FF")); + request.OperationId.Should().Be("create_tag"); result.Should().Contain("Created tag 'Work'"); result.Should().Contain(newId.ToString()); } @@ -77,8 +102,7 @@ public async Task CreateTag_Success_ReturnsCreatedMessage() [Fact] public async Task CreateTag_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Duplicate name")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Duplicate name"); var result = await _tools.CreateTag(_user, "Work", "#0000FF"); @@ -86,22 +110,22 @@ public async Task CreateTag_Failure_ReturnsError() } [Fact] - public async Task UpdateTag_Success_ReturnsUpdatedMessage() + public async Task UpdateTag_Success_RoutesThroughExecutorAndReturnsUpdatedMessage() { var tagId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded, targetId: tagId.ToString()); - var result = await _tools.UpdateTag(_user, tagId.ToString(), "Updated", "#00FF00"); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.UpdateTag(_user, tagId.ToString(), "Updated", "#00FF00")); + request.OperationId.Should().Be("update_tag"); result.Should().Contain("Updated tag"); } [Fact] public async Task UpdateTag_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Tag not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Tag not found"); var result = await _tools.UpdateTag(_user, Guid.NewGuid().ToString(), "Name", "#000"); @@ -109,21 +133,31 @@ public async Task UpdateTag_Failure_ReturnsError() } [Fact] - public async Task DeleteTag_Success_ReturnsDeletedMessage() + public async Task DeleteTag_Success_RoutesThroughExecutorAndReturnsDeletedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.DeleteTag(_user, Guid.NewGuid().ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.DeleteTag(_user, Guid.NewGuid().ToString())); + request.OperationId.Should().Be("delete_tag"); result.Should().Contain("Deleted tag"); } + [Fact] + public async Task DeleteTag_PendingConfirmation_ReturnsConfirmationPrompt() + { + StubExecutor(AgentOperationStatus.PendingConfirmation); + + var result = await _tools.DeleteTag(_user, Guid.NewGuid().ToString()); + + result.Should().Contain("Confirmation required"); + } + [Fact] public async Task DeleteTag_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Tag not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Tag not found"); var result = await _tools.DeleteTag(_user, Guid.NewGuid().ToString()); @@ -131,22 +165,23 @@ public async Task DeleteTag_Failure_ReturnsError() } [Fact] - public async Task AssignTags_Success_WithTags_ReturnsAssignedMessage() + public async Task AssignTags_Success_WithTags_RoutesTagIdsAndReturnsAssignedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var tagId = Guid.NewGuid(); - var result = await _tools.AssignTags(_user, Guid.NewGuid().ToString(), tagId.ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.AssignTags(_user, Guid.NewGuid().ToString(), tagId.ToString())); + request.OperationId.Should().Be("assign_tags"); + request.Arguments.GetRawText().Should().Contain("tag_ids"); result.Should().Contain("Assigned 1 tags"); } [Fact] public async Task AssignTags_Success_Empty_ReturnsRemovedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var result = await _tools.AssignTags(_user, Guid.NewGuid().ToString(), ""); @@ -156,8 +191,7 @@ public async Task AssignTags_Success_Empty_ReturnsRemovedMessage() [Fact] public async Task AssignTags_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); var result = await _tools.AssignTags(_user, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/UserFactToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/UserFactToolsTests.cs index d49f6690..646cba84 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/UserFactToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/UserFactToolsTests.cs @@ -2,26 +2,49 @@ using FluentAssertions; using MediatR; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; -using Orbit.Application.UserFacts.Commands; using Orbit.Application.UserFacts.Queries; using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Mcp; public class UserFactToolsTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly UserFactTools _tools; private readonly ClaimsPrincipal _user; public UserFactToolsTests() { - _tools = new UserFactTools(_mediator); + _tools = new UserFactTools(_mediator, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private void StubExecutor(AgentOperationStatus status, string? policyReason = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", "operation", AgentRiskClass.Destructive, AgentConfirmationRequirement.FreshConfirmation, + status, PolicyReason: policyReason)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + + private async Task CapturedRequestAsync(Func act) + { + await act(); + var calls = _executor.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .ToList(); + calls.Should().NotBeEmpty(); + return (AgentExecuteOperationRequest)calls[^1].GetArguments()[0]!; + } + [Fact] public async Task GetUserFacts_Success_ReturnsFormattedFacts() { @@ -62,23 +85,34 @@ public async Task GetUserFacts_Failure_ReturnsError() } [Fact] - public async Task DeleteUserFact_Success_ReturnsDeletedMessage() + public async Task DeleteUserFact_Success_RoutesToDeleteUserFactsAndReturnsDeletedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var factId = Guid.NewGuid(); - var result = await _tools.DeleteUserFact(_user, factId.ToString()); + string result = string.Empty; + var request = await CapturedRequestAsync(async () => result = await _tools.DeleteUserFact(_user, factId.ToString())); + request.OperationId.Should().Be("delete_user_facts"); + request.Arguments.GetRawText().Should().Contain("fact_id"); result.Should().Contain("Deleted user fact"); result.Should().Contain(factId.ToString()); } + [Fact] + public async Task DeleteUserFact_PendingConfirmation_ReturnsConfirmationPrompt() + { + StubExecutor(AgentOperationStatus.PendingConfirmation); + + var result = await _tools.DeleteUserFact(_user, Guid.NewGuid().ToString()); + + result.Should().Contain("Confirmation required"); + } + [Fact] public async Task DeleteUserFact_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Fact not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Fact not found"); var result = await _tools.DeleteUserFact(_user, Guid.NewGuid().ToString()); diff --git a/tests/Orbit.IntegrationTests/McpMutationExecutorRoutingTests.cs b/tests/Orbit.IntegrationTests/McpMutationExecutorRoutingTests.cs new file mode 100644 index 00000000..e9431fa4 --- /dev/null +++ b/tests/Orbit.IntegrationTests/McpMutationExecutorRoutingTests.cs @@ -0,0 +1,276 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.IntegrationTests; + +[Collection("Sequential")] +public class McpMutationExecutorRoutingTests : IAsyncLifetime +{ + private readonly IntegrationTestWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly string _email = $"mcp-mutation-{Guid.NewGuid()}@integration.test"; + private const string TestCode = "999999"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private Guid _userId; + + public McpMutationExecutorRoutingTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + IntegrationTestHelpers.RegisterTestAccount(_email, TestCode); + } + + public async Task InitializeAsync() + { + var login = await IntegrationTestHelpers.AuthenticateWithCodeAsync(_client, _email, TestCode, JsonOptions); + _userId = login.UserId; + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + private static JsonElement BuildArguments(object value) => JsonSerializer.SerializeToElement(value); + + private async Task AssertAuditRowAsync(IServiceScope scope, string capabilityId, string sourceName) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var auditRow = await dbContext.AgentAuditLogs + .Where(log => log.UserId == _userId + && log.CapabilityId == capabilityId + && log.Surface == AgentExecutionSurface.Mcp + && log.OutcomeStatus == AgentOperationStatus.Succeeded + && log.SourceName == sourceName) + .OrderByDescending(log => log.CreatedAtUtc) + .FirstOrDefaultAsync(); + + auditRow.Should().NotBeNull(); + } + + // --- Tag: create_tag → tags.write (not plan-gated) --- + + [Fact] + public async Task CreateTag_ViaMcpSurface_WritesAuditRow() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_tag", + BuildArguments(new { name = $"Routed-{Guid.NewGuid():N}", color = "#FF5733" }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + await AssertAuditRowAsync(scope, "tags.write", "create_tag"); + } + + // --- Tag (hard case #1): assign_tags id path → tags.write --- + + [Fact] + public async Task AssignTags_ViaMcpSurface_ReplacesTagsByIdAndWritesAuditRow() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var habitId = await SeedHabitAsync(executor); + var tagId = await SeedTagAsync(executor); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "assign_tags", + BuildArguments(new { habit_id = habitId.ToString(), tag_ids = new[] { tagId.ToString() } }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + await AssertAuditRowAsync(scope, "tags.write", "assign_tags"); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var habit = await dbContext.Habits + .Include(h => h.Tags) + .FirstAsync(h => h.Id == habitId); + habit.Tags.Select(t => t.Id).Should().ContainSingle().Which.Should().Be(tagId); + } + + // --- Profile (mapped): update_profile_preferences → profile.preferences.write (not gated) --- + + [Fact] + public async Task UpdateProfilePreferences_ViaMcpSurface_WritesAuditRow() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "update_profile_preferences", + BuildArguments(new { action = "set_timezone", timezone = "America/Sao_Paulo" }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + await AssertAuditRowAsync(scope, "profile.preferences.write", "update_profile_preferences"); + } + + // --- Notification (mapped): update_notifications → notifications.write (not gated) --- + + [Fact] + public async Task UpdateNotifications_ViaMcpSurface_WritesAuditRow() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "update_notifications", + BuildArguments(new { action = "mark_all_read" }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + await AssertAuditRowAsync(scope, "notifications.write", "update_notifications"); + } + + // --- Referral (hard case #2): get_referral_code → referrals.write --- + + [Fact] + public async Task GetReferralCode_ViaMcpSurface_WritesAuditRowAndReturnsCode() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "get_referral_code", + BuildArguments(new { }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + response.Operation.TargetName.Should().NotBeNullOrWhiteSpace(); + await AssertAuditRowAsync(scope, "referrals.write", "get_referral_code"); + } + + // --- UserFact delete is Destructive: without a token → PendingConfirmation --- + + [Fact] + public async Task DeleteUserFacts_ViaMcpSurface_WithoutToken_IsPendingConfirmation() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + // Seed an owned fact so the ownership pre-check passes and the confirmation gate is what fires. + var factId = await SeedUserFactAsync(scope); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "delete_user_facts", + BuildArguments(new { fact_id = factId.ToString() }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.PendingConfirmation); + } + + // --- Read-only credential is denied across newly-routed toolsets (fires before any gate) --- + + [Fact] + public async Task ReadOnlyCredential_IsDeniedAcrossNewlyRoutedToolsets() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var scopes = AgentScopes.ClaudeDefaultScopes; + + // assign_tags carries a habit target; seed an owned habit so the ownership pre-check passes + // and the read-only-credential denial (the assertion target) is what fires. delete_user_facts + // is invoked target-free so its ownership check is skipped for the same reason. + var ownedHabitId = await SeedHabitAsync(executor); + + var cases = new (string OperationId, object Arguments)[] + { + ("create_tag", new { name = "Denied", color = "#FF0000" }), + ("assign_tags", new { habit_id = ownedHabitId.ToString(), tag_ids = Array.Empty() }), + ("update_profile_preferences", new { action = "set_timezone", timezone = "America/Sao_Paulo" }), + ("update_notifications", new { action = "mark_all_read" }), + ("delete_user_facts", new { }), + ("get_referral_code", new { }) + }; + + foreach (var (operationId, arguments) in cases) + { + var denial = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + operationId, + BuildArguments(arguments), + AgentExecutionSurface.Mcp, + AgentAuthMethod.ApiKey, + scopes, + IsReadOnlyCredential: true)); + + denial.Operation.Status.Should().Be(AgentOperationStatus.Denied, $"{operationId} should be denied for a read-only credential"); + denial.Operation.PolicyReason.Should().Be("read_only_credential", $"{operationId} denial reason"); + } + } + + private async Task SeedHabitAsync(IAgentOperationExecutor executor) + { + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_habit", + BuildArguments(new + { + title = "Seed habit", + due_date = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), + frequency_unit = "Day" + }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + Guid.TryParse(response.Operation.TargetId, out var habitId).Should().BeTrue(); + return habitId; + } + + private async Task SeedTagAsync(IAgentOperationExecutor executor) + { + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_tag", + BuildArguments(new { name = $"Seed-{Guid.NewGuid():N}", color = "#123456" }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + Guid.TryParse(response.Operation.TargetId, out var tagId).Should().BeTrue(); + return tagId; + } + + private async Task SeedUserFactAsync(IServiceScope scope) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var fact = UserFact.Create(_userId, "Prefers morning workouts", "Fitness").Value; + dbContext.UserFacts.Add(fact); + await dbContext.SaveChangesAsync(); + return fact.Id; + } +}