diff --git a/src/Orbit.Api/Controllers/AiController.cs b/src/Orbit.Api/Controllers/AiController.cs index 3f526dfe..b944ae45 100644 --- a/src/Orbit.Api/Controllers/AiController.cs +++ b/src/Orbit.Api/Controllers/AiController.cs @@ -1,8 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; using Orbit.Api.RateLimiting; +using Orbit.Application.Chat.Models; +using Orbit.Application.Common; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; @@ -15,9 +20,11 @@ public class AiController( IAgentCatalogService catalogService, IAgentPolicyEvaluator policyEvaluator, IPendingAgentOperationStore pendingOperationStore, + IPendingClarificationStore pendingClarificationStore, IAgentStepUpService stepUpService, IAgentAuditService auditService, - IAgentOperationExecutor operationExecutor) : ControllerBase + IAgentOperationExecutor operationExecutor, + IValidator resolveClarificationValidator) : ControllerBase { [HttpGet("capabilities")] public async Task GetCapabilitiesMetadata(CancellationToken cancellationToken) @@ -294,4 +301,196 @@ await auditService.RecordAsync(new AgentAuditEntry( return Ok(result); } + + [HttpPost("clarifications/{operationId:guid}/resolve")] + [DistributedRateLimit("ai-resolve")] + public async Task ResolveClarification( + Guid operationId, + [FromBody] ResolveClarificationRequest body, + CancellationToken cancellationToken) + { + // Clarification cards are a UI-only flow — API-key clients can't render or tap them. + // Mirrors the guard on ExecutePendingOperation and the step-up endpoints. + var authMethod = HttpContext.User.GetAgentAuthMethod(); + if (authMethod == AgentAuthMethod.ApiKey) + return Forbid(); + + var userId = HttpContext.GetUserId(); + + var validation = await resolveClarificationValidator.ValidateAsync(body, cancellationToken); + if (!validation.IsValid) + { + var firstError = validation.Errors[0]; + await RecordResolveAuditAsync( + userId, + authMethod, + operationId, + AgentPolicyDecisionStatus.Denied, + AgentOperationStatus.Failed, + $"invalid_clarification_value:{firstError.PropertyName}", + cancellationToken); + return BadRequest(new { error = firstError.ErrorMessage }); + } + + var pending = await pendingClarificationStore.GetForResolutionAsync(operationId, userId, cancellationToken); + if (pending is null) + { + await RecordResolveAuditAsync( + userId, + authMethod, + operationId, + AgentPolicyDecisionStatus.Denied, + AgentOperationStatus.Failed, + "clarification_not_found", + cancellationToken); + return NotFound(new { error = ErrorMessages.ClarificationNotFound }); + } + + // The patch must be one of the server-offered quick-action values. This is a + // defense-in-depth check: prevents a malicious client from hand-crafting a patch + // that overrides fields the contract never said could be changed. + if (!pending.AllowedValues.Contains(body.Value, StringComparer.Ordinal)) + { + await RecordResolveAuditAsync( + userId, + authMethod, + operationId, + AgentPolicyDecisionStatus.Denied, + AgentOperationStatus.Failed, + "clarification_value_not_offered", + cancellationToken); + return BadRequest(new { error = ErrorMessages.ClarificationValueNotOffered }); + } + + JsonElement mergedArgs; + try + { + mergedArgs = MergeClarificationValue(pending.PartialArgumentsJson, body.Value); + } + catch (JsonException) + { + await RecordResolveAuditAsync( + userId, + authMethod, + operationId, + AgentPolicyDecisionStatus.Denied, + AgentOperationStatus.Failed, + "invalid_clarification_value", + cancellationToken); + return BadRequest(new { error = ErrorMessages.ClarificationValueNotJsonObject }); + } + + // Atomic one-shot claim: if this returns false, either another concurrent request + // already marked the row resolved OR the row expired in the (typically sub-ms) + // window between Get and MarkResolved. Bail before re-invoking. + // + // The clarification is intentionally consumed BEFORE ExecuteAsync runs. If the + // executor throws or the tool returns Failed/Denied, the clarification is gone — + // the user must re-initiate the request via chat. This is acceptable because: + // (a) the alternative (un-claim on failure) reopens TOCTOU races on retry, + // (b) tool Failed/Denied is surfaced in the response so the client can prompt + // the user appropriately, + // (c) re-asking in chat is a natural recovery path the user already understands. + var claimed = await pendingClarificationStore.MarkResolvedAsync(operationId, userId, cancellationToken); + if (!claimed) + { + var auditError = pending.ExpiresAtUtc <= DateTime.UtcNow + ? "clarification_expired_mid_flight" + : "clarification_already_resolved"; + await RecordResolveAuditAsync( + userId, + authMethod, + operationId, + AgentPolicyDecisionStatus.Denied, + AgentOperationStatus.Failed, + auditError, + cancellationToken); + return Conflict(new { error = ErrorMessages.ClarificationAlreadyResolved }); + } + + var result = await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest( + userId, + pending.ToolName, + mergedArgs, + AgentExecutionSurface.Chat, + authMethod, + HttpContext.User.GetGrantedAgentScopes(), + HttpContext.User.IsReadOnlyCredential(), + ConfirmationToken: null, + HttpContext.TraceIdentifier), cancellationToken); + + await RecordResolveAuditAsync( + userId, + authMethod, + operationId, + result.Operation.Status == AgentOperationStatus.Succeeded + ? AgentPolicyDecisionStatus.Allowed + : AgentPolicyDecisionStatus.Denied, + result.Operation.Status, + result.Operation.PolicyReason, + cancellationToken, + targetName: result.Operation.TargetName); + + return Ok(result); + } + + private Task RecordResolveAuditAsync( + Guid userId, + AgentAuthMethod authMethod, + Guid operationId, + AgentPolicyDecisionStatus policyDecision, + AgentOperationStatus outcome, + string? error, + CancellationToken cancellationToken, + string? targetName = null) + { + return auditService.RecordAsync(new AgentAuditEntry( + userId, + AgentCapabilityIds.ChatInteract, + nameof(ResolveClarification), + AgentExecutionSurface.Chat, + authMethod, + AgentRiskClass.Low, + policyDecision, + outcome, + HttpContext.TraceIdentifier, + "Resolve clarification", + TargetId: operationId.ToString(), + TargetName: targetName, + Error: error), cancellationToken); + } + + private static JsonElement MergeClarificationValue(string baseJson, string value) + { + // Fail closed if the stored args aren't a JSON object — silently coercing to {} + // would drop the original tool arguments and replay the tool with only the patch. + if (JsonNode.Parse(baseJson) is not JsonObject baseNode) + throw new JsonException("Stored partial arguments are not a JSON object."); + + if (!string.IsNullOrWhiteSpace(value)) + { + if (JsonNode.Parse(value) is not JsonObject patchNode) + throw new JsonException("Clarification value must be a JSON object."); + + DeepMerge(baseNode, patchNode); + } + + return JsonDocument.Parse(baseNode.ToJsonString()).RootElement.Clone(); + } + + // Deep merge: nested JsonObjects recurse instead of clobbering. + private static void DeepMerge(JsonObject target, JsonObject patch) + { + foreach (var kvp in patch.ToList()) + { + if (target[kvp.Key] is JsonObject targetChild && kvp.Value is JsonObject patchChild) + { + DeepMerge(targetChild, patchChild); + } + else + { + target[kvp.Key] = kvp.Value?.DeepClone(); + } + } + } } diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index fca05935..a283f1e0 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -142,6 +142,7 @@ 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(); @@ -255,7 +256,8 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); return builder; } diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index e380f791..4e7e2d17 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -3,6 +3,7 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Orbit.Application.Chat.Models; using Orbit.Application.Chat.Tools; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -42,9 +43,10 @@ public record ActionResult( string? EntityName = null, string? Error = null, string? Field = null, - IReadOnlyList? SuggestedSubHabits = null); + IReadOnlyList? SuggestedSubHabits = null, + ClarificationRequest? ClarificationRequest = null); -public enum ActionStatus { Success, Failed, Suggestion } +public enum ActionStatus { Success, Failed, Suggestion, NeedsClarification } /// /// Groups AI-related dependencies to reduce constructor parameter count (S107). @@ -76,7 +78,8 @@ public record ChatExecutionDependencies( IPayGateService PayGateService, IUnitOfWork UnitOfWork, IServiceScopeFactory ServiceScopeFactory, - IAgentOperationExecutor OperationExecutor); + IAgentOperationExecutor OperationExecutor, + IPendingClarificationStore PendingClarificationStore); public partial class ProcessUserChatCommandHandler( ChatDataDependencies data, @@ -363,11 +366,70 @@ public async Task> Handle( var operationResult = executionResponse.Operation; var toolResult = BuildToolCallResult(call, operationResult); + var isClarification = operationResult.Payload is NeedsClarificationPayload; - if (operationResult.Status == AgentOperationStatus.Succeeded) + if (operationResult.Status == AgentOperationStatus.Succeeded && !isClarification) LogToolSucceeded(logger, call.Name, operationResult.TargetName); else if (operationResult.Status is AgentOperationStatus.Failed or AgentOperationStatus.Denied) + { LogToolFailed(logger, call.Name, operationResult.PolicyReason); + if (isClarification) + LogClarificationDroppedOnFailedTool(logger, call.Name, operationResult.PolicyReason); + } + + if (operationResult.Status == AgentOperationStatus.Succeeded + && operationResult.Payload is NeedsClarificationPayload payload) + { + // Serialize null as an empty array literal — JsonSerializer.Serialize(null) + // returns the string "null" which bypasses PendingClarification.Create's + // "[]" default and would break ExtractQuickActionValues on read. + var quickActionsJson = payload.QuickActions is null + ? "[]" + : JsonSerializer.Serialize(payload.QuickActions); + + // Cap the stashed args so a runaway tool argument can't bloat the table. + // 16 KB covers realistic create_habit calls (a few sub_habits with checklists + // and descriptions); larger blows past expected payloads. + var partialArgsJson = call.Args.GetRawText(); + if (partialArgsJson.Length > AppConstants.MaxClarificationArgsLength) + { + LogClarificationArgsTooLarge(logger, call.Name, partialArgsJson.Length); + return ( + toolResult, + new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.Failed, + Error: "Tool arguments exceeded the clarification stash limit."), + operationResult, + executionResponse.PolicyDenial, + executionResponse.PendingOperation); + } + + var stashedId = await execution.PendingClarificationStore.CreateAsync( + request.UserId, + call.Name, + partialArgsJson, + payload.MissingArgumentKey, + payload.Question, + quickActionsJson, + cancellationToken); + LogClarificationRequested(logger, call.Name, stashedId, payload.MissingArgumentKey); + var clarification = new ClarificationRequest( + payload.Question, + stashedId, + payload.MissingArgumentKey, + payload.QuickActions ?? Array.Empty()); + return ( + toolResult, + new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.NeedsClarification, + EntityName: call.Name, + ClarificationRequest: clarification), + operationResult, + executionResponse.PolicyDenial, + executionResponse.PendingOperation); + } return operationResult.Status switch { @@ -760,6 +822,15 @@ private static async Task IncrementAiMessageCountAsync( [LoggerMessage(EventId = 22, Level = LogLevel.Warning, Message = "Background message counter increment failed")] private static partial void LogBackgroundMessageCounterFailed(ILogger logger, Exception ex); + [LoggerMessage(EventId = 24, Level = LogLevel.Information, Message = "Tool {Name} requested clarification (operationId={OperationId}, missing={MissingKey})")] + private static partial void LogClarificationRequested(ILogger logger, string name, Guid operationId, string missingKey); + + [LoggerMessage(EventId = 25, Level = LogLevel.Warning, Message = "Tool {Name} emitted a clarification payload on a Failed/Denied result and it was dropped: {Reason}")] + private static partial void LogClarificationDroppedOnFailedTool(ILogger logger, string name, string? reason); + + [LoggerMessage(EventId = 26, Level = LogLevel.Warning, Message = "Tool {Name} requested clarification with oversized partial args ({Length} chars) — dropped without stashing")] + private static partial void LogClarificationArgsTooLarge(ILogger logger, string name, int length); + [LoggerMessage(EventId = 23, Level = LogLevel.Warning, Message = "Background post-response work failed")] private static partial void LogBackgroundPostResponseFailed(ILogger logger, Exception ex); diff --git a/src/Orbit.Application/Chat/Models/ClarificationRequest.cs b/src/Orbit.Application/Chat/Models/ClarificationRequest.cs new file mode 100644 index 00000000..4b1eb46b --- /dev/null +++ b/src/Orbit.Application/Chat/Models/ClarificationRequest.cs @@ -0,0 +1,18 @@ +namespace Orbit.Application.Chat.Models; + +/// +/// External-facing "I need to ask the user a question" payload. The frontend renders +/// quick-action buttons; tapping one POSTs the chosen QuickAction.Value to +/// POST /api/ai/clarifications/{OperationId}/resolve, which merges the value +/// into the partial arguments stash and re-invokes the original tool deterministically. +/// +/// Tools do not construct this directly — they return a +/// and the chat handler attaches the store-minted OperationId when building the +/// outbound ActionResult. +/// +/// +public record ClarificationRequest( + string Question, + Guid OperationId, + string MissingArgumentKey, + IReadOnlyList QuickActions); diff --git a/src/Orbit.Application/Chat/Models/NeedsClarificationPayload.cs b/src/Orbit.Application/Chat/Models/NeedsClarificationPayload.cs new file mode 100644 index 00000000..53a386bf --- /dev/null +++ b/src/Orbit.Application/Chat/Models/NeedsClarificationPayload.cs @@ -0,0 +1,17 @@ +namespace Orbit.Application.Chat.Models; + +/// +/// Internal payload returned by a tool that wants to ask the user a question instead +/// of executing. The chat handler stashes this server-side, mints an OperationId +/// from the store, and constructs the external-facing +/// before surfacing it to the frontend. +/// +/// Tools should not attempt to populate an OperationId themselves — the handler +/// owns that field. Keeping the tool's output free of the id avoids a Guid.Empty +/// sentinel leaking into the contract. +/// +/// +public record NeedsClarificationPayload( + string Question, + string MissingArgumentKey, + IReadOnlyList? QuickActions); diff --git a/src/Orbit.Application/Chat/Models/QuickAction.cs b/src/Orbit.Application/Chat/Models/QuickAction.cs new file mode 100644 index 00000000..12c61704 --- /dev/null +++ b/src/Orbit.Application/Chat/Models/QuickAction.cs @@ -0,0 +1,16 @@ +namespace Orbit.Application.Chat.Models; + +/// +/// A choice the user can tap on a card. +/// +/// i18n key (or literal text) shown on the button. +/// +/// Opaque token the client must echo back verbatim when resolving the clarification. +/// On the server it carries a JSON merge patch (e.g. {"frequency_unit":"Day","frequency_quantity":1}) +/// that gets deep-merged into the stashed partial arguments. Compared with byte-for-byte +/// equality (StringComparer.Ordinal) against the set of values offered when +/// the clarification was issued — clients MUST NOT re-serialize, re-order keys, or +/// trim whitespace, or the resolve endpoint will reject with 400. +/// +/// Optional secondary label shown below the button. +public record QuickAction(string Label, string Value, string? Description = null); diff --git a/src/Orbit.Application/Chat/Models/ResolveClarificationRequest.cs b/src/Orbit.Application/Chat/Models/ResolveClarificationRequest.cs new file mode 100644 index 00000000..b7dcc436 --- /dev/null +++ b/src/Orbit.Application/Chat/Models/ResolveClarificationRequest.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Orbit.Application.Chat.Models; + +/// +/// Request body for POST /api/ai/clarifications/{operationId}/resolve. +/// Value is a JSON-encoded merge patch (e.g. {"frequency_unit":"Day","frequency_quantity":1}) +/// that gets deep-merged into the stashed partial arguments before the original tool re-runs. +/// +public record ResolveClarificationRequest([property: JsonRequired] string Value); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs index b6b09684..3e526186 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Orbit.Application.Chat.Models; using Orbit.Application.Chat.Tools; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -20,7 +21,7 @@ public class CreateHabitTool( public string Name => "create_habit"; 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. 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]."; + "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]."; public object GetParameterSchema() => new { @@ -146,10 +147,22 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel var title = titleEl.GetString() ?? string.Empty; + // Check PayGate BEFORE the clarification heuristic so a user at their habit + // limit gets the "limit reached" error immediately, not after picking a + // schedule on the card and waiting for the re-invocation to fail. var habitGate = await payGate.CanCreateHabits(userId, 1, ct); if (habitGate.IsFailure) return new ToolResult(false, Error: habitGate.Error); + // TryGetProperty returns true for an explicit null value, so this only fires when + // the key is genuinely absent. The "one-time task" quick action patches with + // {"frequency_unit":null}, which adds the key and bypasses this check on + // re-invocation. Don't change to a value-based check without preserving that. + if (!args.TryGetProperty("frequency_unit", out _) && IsHabitFlavoredTitle(title)) + { + return new ToolResult(true, EntityName: title, Payload: BuildFrequencyClarification()); + } + var today = await userDateService.GetUserTodayAsync(userId, ct); var habitResult = BuildParentHabit(args, userId, title, today); @@ -313,4 +326,37 @@ private async Task AssignTagsToHabitAsync(Habit habit, List tagNames, Gu private static string Capitalize(string s) => string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..].ToLower(); + + // Whole-word "habit" so "inhabit", "habitual", "cohabit" don't false-positive. + // "rotina" and "hábito" are PT-BR and don't collide with common words. The + // unaccented "habito" was previously included but conflicts with the verb + // habitar (e.g., "Eu habito em Lisboa" = "I live in Lisbon"), so we don't + // match it — users typing pt-BR properly will use the accent. + private static readonly System.Text.RegularExpressions.Regex HabitWordRegex = + new(@"\bhabit\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); + + private static bool IsHabitFlavoredTitle(string title) + { + return HabitWordRegex.IsMatch(title) + || title.Contains("rotina", StringComparison.OrdinalIgnoreCase) + || title.Contains("hábito", StringComparison.OrdinalIgnoreCase); + } + + private static NeedsClarificationPayload BuildFrequencyClarification() + { + return new NeedsClarificationPayload( + Question: "habits.clarification.questionFallback", + MissingArgumentKey: "frequency_unit", + QuickActions: new List + { + new("habits.clarification.quickAction.daily", + """{"frequency_unit":"Day","frequency_quantity":1}"""), + new("habits.clarification.quickAction.weekly", + """{"frequency_unit":"Week","frequency_quantity":1}"""), + new("habits.clarification.quickAction.threePerWeek", + """{"frequency_unit":"Week","frequency_quantity":3,"is_flexible":true}"""), + new("habits.clarification.quickAction.oneTime", + """{"frequency_unit":null}"""), + }); + } } diff --git a/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs b/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs new file mode 100644 index 00000000..9a03a0f3 --- /dev/null +++ b/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentValidation; +using Orbit.Application.Chat.Models; +using Orbit.Application.Common; + +namespace Orbit.Application.Chat.Validators; + +public class ResolveClarificationRequestValidator : AbstractValidator +{ + public ResolveClarificationRequestValidator() + { + // Stop on first failure so an empty string doesn't also trip the JSON-object + // check — the controller surfaces only Errors[0] anyway, and the redundant + // failure is noise. + RuleFor(x => x.Value) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .WithMessage(ErrorMessages.ClarificationValueEmpty) + .MaximumLength(AppConstants.MaxClarificationValueLength) + .WithMessage(string.Format(ErrorMessages.ClarificationValueTooLong, AppConstants.MaxClarificationValueLength)) + .Must(BeJsonObject) + .WithMessage(ErrorMessages.ClarificationValueNotJsonObject); + } + + private static bool BeJsonObject(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + return JsonNode.Parse(value) is JsonObject; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index cdad7be2..616d0436 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -33,6 +33,9 @@ public static class AppConstants public const int MaxLanguageLength = 10; public const int MaxVerificationAttempts = 3; public const int MaxChatMessageLength = 4000; + public const int MaxClarificationValueLength = 2048; + public const int MaxClarificationArgsLength = 16384; + public const int PendingClarificationTtlMinutes = 30; public const int MaxChatHistoryLength = 50_000; public const int MaxChatHistoryMessages = 40; public const int MaxChatHistoryMessageLength = 4000; diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs index c6398893..f18fb5d8 100644 --- a/src/Orbit.Application/Common/ErrorMessages.cs +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -32,4 +32,10 @@ public static class ErrorMessages public const string SubjectRequired = "Subject is required"; public const string MessageRequired = "Message is required"; public const string InvalidSession = "Invalid or expired session."; + public const string ClarificationValueEmpty = "Clarification value cannot be empty."; + public const string ClarificationValueTooLong = "Clarification value cannot exceed {0} characters."; + public const string ClarificationValueNotJsonObject = "Clarification value must be a JSON object."; + public const string ClarificationValueNotOffered = "Clarification value is not one of the offered quick actions."; + public const string ClarificationNotFound = "Clarification not found or expired."; + public const string ClarificationAlreadyResolved = "Clarification already resolved."; } diff --git a/src/Orbit.Domain/Entities/PendingClarification.cs b/src/Orbit.Domain/Entities/PendingClarification.cs new file mode 100644 index 00000000..ec8c9113 --- /dev/null +++ b/src/Orbit.Domain/Entities/PendingClarification.cs @@ -0,0 +1,69 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +/// +/// Server-side stash for a tool call that returned NeedsClarification — holds the partial +/// arguments and a description of the missing field. Resolved by POST /api/ai/clarifications/{id}/resolve, +/// which merges the user's chosen value into the partial args and re-invokes the original tool. +/// One-shot: is set on resolution; subsequent resolves are rejected. +/// +public class PendingClarification : Entity +{ + public Guid UserId { get; private set; } + public string ToolName { get; private set; } = null!; + public string PartialArgumentsJson { get; private set; } = "{}"; + public string MissingArgumentKey { get; private set; } = null!; + public string Question { get; private set; } = null!; + public string QuickActionsJson { get; private set; } = "[]"; + public DateTime CreatedAtUtc { get; private set; } + public DateTime ExpiresAtUtc { get; private set; } + // No Resolve() domain method by design: PendingClarificationStore.MarkResolvedAsync + // flips this field via ExecuteUpdateAsync to keep the claim atomic (closes the + // TOCTOU window between Get and MarkResolved). Mirrors the PendingAgentOperationStore + // pattern — the trade-off is that EF change tracking is bypassed for this transition. + public DateTime? ResolvedAtUtc { get; private set; } + + private PendingClarification() + { + } + + public static PendingClarification Create( + Guid userId, + string toolName, + string partialArgumentsJson, + string missingArgumentKey, + string question, + string quickActionsJson, + DateTime expiresAtUtc) + { + if (userId == Guid.Empty) + throw new ArgumentException("userId cannot be empty.", nameof(userId)); + if (string.IsNullOrWhiteSpace(toolName)) + throw new ArgumentException("toolName is required.", nameof(toolName)); + if (string.IsNullOrWhiteSpace(missingArgumentKey)) + throw new ArgumentException("missingArgumentKey is required.", nameof(missingArgumentKey)); + if (string.IsNullOrWhiteSpace(question)) + throw new ArgumentException("question is required.", nameof(question)); + + var createdAtUtc = DateTime.UtcNow; + if (expiresAtUtc <= createdAtUtc) + throw new ArgumentException("expiresAtUtc must be in the future.", nameof(expiresAtUtc)); + + return new PendingClarification + { + UserId = userId, + ToolName = toolName, + PartialArgumentsJson = string.IsNullOrWhiteSpace(partialArgumentsJson) ? "{}" : partialArgumentsJson, + MissingArgumentKey = missingArgumentKey, + Question = question, + QuickActionsJson = string.IsNullOrWhiteSpace(quickActionsJson) ? "[]" : quickActionsJson, + CreatedAtUtc = createdAtUtc, + ExpiresAtUtc = expiresAtUtc + }; + } + + public bool IsExpired(DateTime utcNow) => utcNow >= ExpiresAtUtc; + + public bool IsResolved => ResolvedAtUtc.HasValue; +} diff --git a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs index dbc1822e..2e954fcd 100644 --- a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs +++ b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs @@ -45,6 +45,34 @@ bool TryConsumeFreshConfirmation( bool requireStepUp); } +public interface IPendingClarificationStore +{ + Task CreateAsync( + Guid userId, + string toolName, + string partialArgumentsJson, + string missingArgumentKey, + string question, + string quickActionsJson, + CancellationToken cancellationToken = default); + + Task GetForResolutionAsync( + Guid operationId, + Guid userId, + CancellationToken cancellationToken = default); + + /// + /// Atomically claims the clarification as resolved. Returns true iff this caller + /// is the one that flipped ResolvedAtUtc from null to a value — closes the + /// TOCTOU window between get-and-resolve. Concurrent callers see false and must + /// short-circuit (e.g., 409 Conflict) without re-invoking the tool. + /// + Task MarkResolvedAsync( + Guid operationId, + Guid userId, + CancellationToken cancellationToken = default); +} + public interface IAgentStepUpService { Task> IssueChallengeAsync( diff --git a/src/Orbit.Domain/Models/PendingClarificationData.cs b/src/Orbit.Domain/Models/PendingClarificationData.cs new file mode 100644 index 00000000..ca88bee6 --- /dev/null +++ b/src/Orbit.Domain/Models/PendingClarificationData.cs @@ -0,0 +1,16 @@ +namespace Orbit.Domain.Models; + +/// +/// Data needed to resolve a pending clarification: the original tool name, the partial +/// arguments JSON, the key of the missing argument that the user just supplied, and the +/// set of quick-action values the server originally offered. AllowedValues is the +/// allowlist of acceptable patch payloads — anything else gets rejected before the merge, +/// so a malicious client can't override arbitrary fields by hand-crafting the request. +/// Returned by IPendingClarificationStore.GetForResolutionAsync. +/// +public record PendingClarificationData( + string ToolName, + string PartialArgumentsJson, + string MissingArgumentKey, + IReadOnlyList AllowedValues, + DateTime ExpiresAtUtc); diff --git a/src/Orbit.Infrastructure/Migrations/20260519165526_AddPendingClarifications.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260519165526_AddPendingClarifications.Designer.cs new file mode 100644 index 00000000..5dae33bd --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260519165526_AddPendingClarifications.Designer.cs @@ -0,0 +1,1569 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260519165526_AddPendingClarifications")] + partial class AddPendingClarifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore") + .IsUnique(); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260519165526_AddPendingClarifications.cs b/src/Orbit.Infrastructure/Migrations/20260519165526_AddPendingClarifications.cs new file mode 100644 index 00000000..ec1ee714 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260519165526_AddPendingClarifications.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddPendingClarifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PendingClarifications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ToolName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PartialArgumentsJson = table.Column(type: "jsonb", nullable: false), + MissingArgumentKey = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Question = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + QuickActionsJson = table.Column(type: "jsonb", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ResolvedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PendingClarifications", x => x.Id); + table.ForeignKey( + name: "FK_PendingClarifications_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PendingClarifications_ExpiresAtUtc", + table: "PendingClarifications", + column: "ExpiresAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_PendingClarifications_UserId_CreatedAtUtc", + table: "PendingClarifications", + columns: new[] { "UserId", "CreatedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PendingClarifications"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 51d184c8..0bb23c38 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -937,6 +937,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PendingAgentOperations"); }); + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => { b.Property("Id") @@ -1463,6 +1513,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => { b.HasOne("Orbit.Domain.Entities.User", null) diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index 72993a37..1da9700a 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -44,6 +44,7 @@ public OrbitDbContext(DbContextOptions options, IEncryptionServi public DbSet UserSessions => Set(); public DbSet ApiKeys => Set(); public DbSet PendingAgentOperations => Set(); + public DbSet PendingClarifications => Set(); public DbSet AgentStepUpChallenges => Set(); public DbSet AgentAuditLogs => Set(); public DbSet DistributedRateLimitBuckets => Set(); @@ -175,6 +176,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(item => item.CodeHash).HasMaxLength(64); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(item => new { item.UserId, item.CreatedAtUtc }); + entity.HasIndex(item => item.ExpiresAtUtc); + entity.Property(item => item.ToolName).HasMaxLength(100); + entity.Property(item => item.MissingArgumentKey).HasMaxLength(100); + entity.Property(item => item.Question).HasMaxLength(500); + entity.Property(item => item.PartialArgumentsJson).HasColumnType(JsonbColumnType); + entity.Property(item => item.QuickActionsJson).HasColumnType(JsonbColumnType); + entity.HasOne().WithMany().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasIndex(item => new { item.UserId, item.CreatedAtUtc }); diff --git a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs index 501d33ec..722abcbc 100644 --- a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs +++ b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs @@ -15,6 +15,11 @@ public class DistributedRateLimitService(OrbitDbContext dbContext) : IDistribute { ["auth"] = new(TimeSpan.FromMinutes(1), PermitLimit: 5, SegmentCount: 1), ["chat"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4), + // Resolve buckets are separate from chat so a user who exhausts their chat + // quota can still tap quick-action buttons on cards already on screen. + // Each clarification is one-shot anyway; the limit is essentially a misuse + // guard, not a UX throttle. + ["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4), ["support"] = new(TimeSpan.FromHours(1), PermitLimit: 3, SegmentCount: 1) }; diff --git a/src/Orbit.Infrastructure/Services/PendingClarificationStore.cs b/src/Orbit.Infrastructure/Services/PendingClarificationStore.cs new file mode 100644 index 00000000..01d529f4 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/PendingClarificationStore.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Chat.Models; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Services; + +public class PendingClarificationStore(OrbitDbContext dbContext) : IPendingClarificationStore +{ + private static readonly JsonSerializerOptions QuickActionDeserializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public async Task CreateAsync( + Guid userId, + string toolName, + string partialArgumentsJson, + string missingArgumentKey, + string question, + string quickActionsJson, + CancellationToken cancellationToken = default) + { + var entity = PendingClarification.Create( + userId, + toolName, + partialArgumentsJson, + missingArgumentKey, + question, + quickActionsJson, + DateTime.UtcNow.AddMinutes(AppConstants.PendingClarificationTtlMinutes)); + + dbContext.PendingClarifications.Add(entity); + // SaveChangesAsync runs here (eagerly, before the chat command's UoW commits) + // so the OperationId is durably stored before the response is returned. If the + // surrounding chat command fails afterwards, the row is orphaned but the + // 30-minute TTL + index on ExpiresAtUtc reclaim it. Mirrors the eager-save + // pattern in PendingAgentOperationStore. + await dbContext.SaveChangesAsync(cancellationToken); + + return entity.Id; + } + + public async Task GetForResolutionAsync( + Guid operationId, + Guid userId, + CancellationToken cancellationToken = default) + { + // Push the expiry + resolved filters into SQL so an unusable row never crosses + // the wire. Collapses "not found", "expired", and "already resolved" into a + // single null return — the controller treats all three as "not available" and + // distinguishes them later via the atomic MarkResolved + ExpiresAtUtc field. + var now = DateTime.UtcNow; + var entity = await dbContext.PendingClarifications + .AsNoTracking() + .FirstOrDefaultAsync( + item => item.Id == operationId + && item.UserId == userId + && item.ResolvedAtUtc == null + && item.ExpiresAtUtc > now, + cancellationToken); + + if (entity is null) + return null; + + return new PendingClarificationData( + entity.ToolName, + entity.PartialArgumentsJson, + entity.MissingArgumentKey, + ExtractQuickActionValues(entity.QuickActionsJson), + entity.ExpiresAtUtc); + } + + public async Task MarkResolvedAsync( + Guid operationId, + Guid userId, + CancellationToken cancellationToken = default) + { + // Atomic compare-and-set: also requires the row to be unexpired so a client + // that catches the row right at the TTL boundary can't claim it. + var rows = await dbContext.PendingClarifications + .Where(item => + item.Id == operationId && + item.UserId == userId && + item.ResolvedAtUtc == null && + item.ExpiresAtUtc > DateTime.UtcNow) + .ExecuteUpdateAsync( + setter => setter.SetProperty(item => item.ResolvedAtUtc, DateTime.UtcNow), + cancellationToken); + + return rows > 0; + } + + private static IReadOnlyList ExtractQuickActionValues(string quickActionsJson) + { + if (string.IsNullOrWhiteSpace(quickActionsJson)) + return Array.Empty(); + + try + { + // Case-insensitive deserialize decouples this from whichever casing the + // serializer used when storing — defaults to PascalCase for records, but + // a global camelCase policy would silently break a manual JsonNode lookup. + var actions = JsonSerializer.Deserialize>(quickActionsJson, QuickActionDeserializerOptions); + if (actions is null) return Array.Empty(); + + var values = new List(actions.Count); + foreach (var action in actions) + { + if (!string.IsNullOrEmpty(action?.Value)) + values.Add(action.Value); + } + return values; + } + catch (JsonException) + { + return Array.Empty(); + } + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/ClarificationGuidanceSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/ClarificationGuidanceSection.cs new file mode 100644 index 00000000..88b8fbf5 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/ClarificationGuidanceSection.cs @@ -0,0 +1,34 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Static; + +public class ClarificationGuidanceSection : IPromptSection +{ + public int Order => 260; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(""" + ## Clarification Cards (NeedsClarification) + + Some tools may return a structured "I need to ask the user a question" result instead of executing. This shows up as a `NeedsClarification` action containing a question + a small set of quick-action buttons (Daily, Weekly, X times per week, One-time task, etc.). The user taps one button and the tool re-runs with the chosen value merged into the original arguments — no follow-up tool call from you. + + ### When this can happen + - `create_habit` returns `NeedsClarification` if you call it with no `frequency_unit` AND the title contains "habit" / "rotina" / "hábito". Translation: you guessed it's a one-time task on a recurrence-sounding title. The tool refuses to silently create a one-time task in that case. + + ### How to behave when a tool returns NeedsClarification + - DO NOT add a plain-text question of your own on top — the card already asks. Replying with extra text duplicates the prompt and confuses the user. + - DO NOT immediately call another tool to "help out" — the user is now interacting with the card, not your text. + - A short acknowledgement like "Sure — what schedule would you like?" is OK but unnecessary; one short line max, or stay silent. + - The user's button tap triggers the tool to re-run server-side. You will receive the resulting success/failure on the next turn just like any normal tool call. + + ### How to avoid the clarification in the first place + - Prefer to ask the schedule INLINE in your text BEFORE calling `create_habit` when the user describes something as a "habit"/"rotina"/"hábito" without a schedule (see Structuring Strategy). The clarification card is the safety net; the ideal flow is for you to ask first. + - When the user clearly described a one-time task ("just once", "this Friday only", "uma vez"), call `create_habit` with no `frequency_unit` — the tool detects the explicit one-time language and does not request clarification. + + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/StructuringStrategySection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/StructuringStrategySection.cs index 9c7e8382..185c287b 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/StructuringStrategySection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/StructuringStrategySection.cs @@ -38,6 +38,7 @@ public string Build(PromptContext context) - User describes a SINGLE weekly occurrence (e.g., "weekly study routine", "every week I want to clean") with no specific day named -> ask which day of the week - User says a vague time like "morning" or "evening" without a specific hour - Structure is genuinely ambiguous between checklist and sub-habits -> ask "do you want these as a single checklist or individually trackable steps?" + - User calls something a "habit" / "rotina" / "hábito" without stating daily / weekly / X times per week / a specific schedule -> ASK before calling create_habit. Offer: daily, weekly with specific days, X times per week, or one-time task. Prefer returning a NeedsClarification clarification card from create_habit (with the four quick actions) over a plain-text question. - Pick the SINGLE most blocking question. NEVER ask more than one at a time. - NEVER ask if the user already gave a clear answer elsewhere in the message. - After the user answers, act immediately. Do not ask a second round of questions. diff --git a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs index 7ce0236f..d2d67f5a 100644 --- a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs +++ b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs @@ -17,6 +17,7 @@ public SystemPromptBuilder() new CoreIdentitySection(), new GlobalRulesSection(), new StructuringStrategySection(), + new ClarificationGuidanceSection(), new ActiveHabitsSection(), new ActiveGoalsSection(), new UserTagsSection(), diff --git a/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolClarificationTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolClarificationTests.cs new file mode 100644 index 00000000..a3d96e2e --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolClarificationTests.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Chat.Models; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Chat.Tools; + +/// +/// Tests the NeedsClarification heuristic: when frequency_unit is absent AND the title +/// contains habit/rotina/hábito, the tool returns a ClarificationRequest payload instead +/// of creating a one-time task. +/// +public class CreateHabitToolClarificationTests +{ + private readonly IGenericRepository _habitRepo = Substitute.For>(); + private readonly IGenericRepository _tagRepo = Substitute.For>(); + private readonly IGenericRepository _goalRepo = Substitute.For>(); + private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IPayGateService _payGate = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly CreateHabitTool _tool; + + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 5, 19); + + public CreateHabitToolClarificationTests() + { + _tool = new CreateHabitTool(_habitRepo, _tagRepo, _goalRepo, _userDateService, _payGate, _unitOfWork); + _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); + _payGate.CanCreateHabits(UserId, Arg.Any(), Arg.Any()).Returns(Result.Success()); + } + + [Theory] + [InlineData("My morning habit")] + [InlineData("Meditation habit")] + [InlineData("MORNING HABIT")] // case-insensitive + [InlineData("Daily Habit Routine")] + [InlineData("Minha rotina matinal")] // pt-BR + [InlineData("Rotina de leitura")] + [InlineData("Meu hábito de meditar")] // pt-BR with accent + [InlineData("Hábito de exercício")] + public async Task HabitFlavoredTitle_NoFrequency_ReturnsClarification(string title) + { + var result = await Execute($$"""{"title": "{{title}}"}"""); + + result.Success.Should().BeTrue(); + result.Payload.Should().BeOfType(); + var payload = (NeedsClarificationPayload)result.Payload!; + payload.MissingArgumentKey.Should().Be("frequency_unit"); + payload.QuickActions.Should().HaveCount(4); + + // Assert on the JSON merge patches (the load-bearing contract), not the i18n key labels. + // Each patch is what gets shallow-merged into the partial args at resolve time. + payload.QuickActions.Should().Contain(a => + a.Value.Contains("\"frequency_unit\":\"Day\"") && a.Value.Contains("\"frequency_quantity\":1")); + payload.QuickActions.Should().Contain(a => + a.Value.Contains("\"frequency_unit\":\"Week\"") + && a.Value.Contains("\"frequency_quantity\":1") + && !a.Value.Contains("is_flexible")); + payload.QuickActions.Should().Contain(a => + a.Value.Contains("\"frequency_unit\":\"Week\"") + && a.Value.Contains("\"frequency_quantity\":3") + && a.Value.Contains("\"is_flexible\":true")); + payload.QuickActions.Should().Contain(a => + a.Value.Contains("\"frequency_unit\":null")); + + // Tool must NOT have created a habit + await _habitRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task HabitFlavoredTitle_WithFrequency_CreatesNormally() + { + var result = await Execute("""{"title": "Morning habit", "frequency_unit": "Day", "frequency_quantity": 1}"""); + + result.Success.Should().BeTrue(); + result.Payload.Should().BeNull(); + result.EntityId.Should().NotBeNullOrEmpty(); + await _habitRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NonHabitFlavoredTitle_NoFrequency_CreatesOneTimeTask() + { + var result = await Execute("""{"title": "Call the dentist on Friday"}"""); + + result.Success.Should().BeTrue(); + result.Payload.Should().BeNull(); + result.EntityId.Should().NotBeNullOrEmpty(); + await _habitRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HabitFlavoredTitle_WithExplicitNullFrequency_CreatesOneTimeTask() + { + // After a clarification resolves with the "One-time" patch, the merged args + // include "frequency_unit": null. The presence of the key bypasses the check. + var result = await Execute("""{"title": "Habit thing", "frequency_unit": null}"""); + + result.Success.Should().BeTrue(); + result.Payload.Should().BeNull(); + result.EntityId.Should().NotBeNullOrEmpty(); + await _habitRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task MissingTitle_StillReturnsError() + { + var result = await Execute("{}"); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("title is required"); + result.Payload.Should().BeNull(); + } + + [Fact] + public async Task ClarificationQuickActions_ContainValidJsonPatches() + { + var result = await Execute("""{"title": "Morning habit"}"""); + + var payload = (NeedsClarificationPayload)result.Payload!; + foreach (var action in payload.QuickActions) + { + var parsed = () => JsonDocument.Parse(action.Value); + parsed.Should().NotThrow($"QuickAction '{action.Label}' value should be valid JSON"); + } + } + + [Fact] + public async Task ClarificationPayload_DoesNotCarryOperationId() + { + // OperationId is owned by the chat handler — the tool's payload type doesn't + // even expose the field. Keeps the tool decoupled from the store's id minting. + var result = await Execute("""{"title": "Morning habit"}"""); + + result.Payload.Should().BeOfType(); + // No OperationId property on NeedsClarificationPayload — confirmed by the type. + } + + private async Task Execute(string argsJson) + { + using var doc = JsonDocument.Parse(argsJson); + return await _tool.ExecuteAsync(doc.RootElement, UserId, CancellationToken.None); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs index a6815151..9f81e815 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs @@ -167,7 +167,7 @@ public async Task PayGateFails_ReturnsError() _payGate.CanCreateHabits(UserId, Arg.Any(), Arg.Any()) .Returns(Result.Failure("Habit limit reached.")); - var result = await Execute("""{"title": "New Habit"}"""); + var result = await Execute("""{"title": "New Workout"}"""); result.Success.Should().BeFalse(); result.Error.Should().Contain("Habit limit reached"); @@ -382,7 +382,7 @@ public async Task WithMultipleTagsAndGoals_CreatesAndAssignsAll() var result = await Execute($$$""" { - "title": "Full Habit", + "title": "Full Workout", "tag_names": ["Health", "Fitness", "Morning"], "goal_ids": ["{{{goal1.Id}}}", "{{{goal2.Id}}}"] } diff --git a/tests/Orbit.Application.Tests/Chat/Validators/ResolveClarificationRequestValidatorTests.cs b/tests/Orbit.Application.Tests/Chat/Validators/ResolveClarificationRequestValidatorTests.cs new file mode 100644 index 00000000..4d6861e9 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/Validators/ResolveClarificationRequestValidatorTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Orbit.Application.Chat.Models; +using Orbit.Application.Chat.Validators; +using Orbit.Application.Common; + +namespace Orbit.Application.Tests.Chat.Validators; + +public class ResolveClarificationRequestValidatorTests +{ + private readonly ResolveClarificationRequestValidator _validator = new(); + + [Fact] + public void ValidJsonObjectValue_Passes() + { + var result = _validator.Validate(new ResolveClarificationRequest("{\"frequency_unit\":\"Day\"}")); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void EmptyValue_Fails() + { + var result = _validator.Validate(new ResolveClarificationRequest("")); + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Value"); + } + + [Fact] + public void WhitespaceValue_Fails() + { + var result = _validator.Validate(new ResolveClarificationRequest(" ")); + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Value"); + } + + [Fact] + public void TooLongValue_Fails() + { + var payload = new string('x', AppConstants.MaxClarificationValueLength + 1); + var result = _validator.Validate(new ResolveClarificationRequest(payload)); + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed")); + } + + [Fact] + public void MaxLengthValidJsonObject_Passes() + { + // Padding inside a valid JSON object so the value parses AND hits exactly MaxLength. + const string prefix = "{\"k\":\""; + const string suffix = "\"}"; + var fillerLength = AppConstants.MaxClarificationValueLength - prefix.Length - suffix.Length; + var payload = prefix + new string('x', fillerLength) + suffix; + payload.Length.Should().Be(AppConstants.MaxClarificationValueLength); + + var result = _validator.Validate(new ResolveClarificationRequest(payload)); + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("not json at all")] + [InlineData("[]")] // JSON array, not object + [InlineData("\"a string\"")] // JSON string, not object + [InlineData("42")] // JSON number, not object + [InlineData("null")] // JSON null + [InlineData("true")] // JSON bool + public void NonObjectValue_Fails(string value) + { + var result = _validator.Validate(new ResolveClarificationRequest(value)); + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Value"); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index 1de2f9e8..6d56e339 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -33,6 +33,7 @@ public class ProcessUserChatCommandHandlerTests private readonly IServiceScopeFactory _scopeFactory = Substitute.For(); private readonly IAgentCatalogService _catalogService = Substitute.For(); private readonly IAgentOperationExecutor _operationExecutor = Substitute.For(); + private readonly IPendingClarificationStore _pendingClarificationStore = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private static readonly Guid UserId = Guid.NewGuid(); @@ -60,7 +61,7 @@ private ProcessUserChatCommandHandler CreateHandler(params IAiTool[] tools) var aiDeps = new ChatAiDependencies(_aiIntentService, toolRegistry, _promptBuilder, _catalogService); var dataDeps = new ChatDataDependencies(_habitRepo, _goalRepo, _userRepo, _userFactRepo, _tagRepo, _checklistTemplateRepo, _featureFlagService); var executionDeps = new ChatExecutionDependencies( - _userDateService, _userStreakService, _payGate, _unitOfWork, _scopeFactory, _operationExecutor); + _userDateService, _userStreakService, _payGate, _unitOfWork, _scopeFactory, _operationExecutor, _pendingClarificationStore); return new ProcessUserChatCommandHandler( dataDeps, aiDeps, executionDeps, _logger); diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs index f340fe82..e4fc37dc 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs @@ -1,10 +1,12 @@ using System.Security.Claims; using System.Text.Json; using FluentAssertions; +using FluentValidation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NSubstitute; using Orbit.Api.Controllers; +using Orbit.Application.Chat.Models; using Orbit.Domain.Common; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; @@ -16,9 +18,12 @@ public class AiControllerTests private readonly IAgentCatalogService _catalogService = Substitute.For(); private readonly IAgentPolicyEvaluator _policyEvaluator = Substitute.For(); private readonly IPendingAgentOperationStore _pendingOperationStore = Substitute.For(); + private readonly IPendingClarificationStore _pendingClarificationStore = Substitute.For(); private readonly IAgentStepUpService _stepUpService = Substitute.For(); private readonly IAgentAuditService _auditService = Substitute.For(); private readonly IAgentOperationExecutor _operationExecutor = Substitute.For(); + private readonly IValidator _resolveClarificationValidator = + Substitute.For>(); private readonly AiController _controller; private static readonly Guid UserId = Guid.NewGuid(); @@ -28,9 +33,11 @@ public AiControllerTests() _catalogService, _policyEvaluator, _pendingOperationStore, + _pendingClarificationStore, _stepUpService, _auditService, - _operationExecutor); + _operationExecutor, + _resolveClarificationValidator); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, UserId.ToString()) }; var identity = new ClaimsIdentity(claims, "Test"); @@ -375,6 +382,143 @@ public async Task ExecutePendingOperation_ForApiKeyUser_ReturnsForbid() result.Should().BeOfType(); } + [Fact] + public async Task ResolveClarification_ForApiKeyUser_ReturnsForbid() + { + SetUser(isApiKey: true); + + var result = await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest("{\"frequency_unit\":\"Day\"}"), + CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task ResolveClarification_InvalidBody_ReturnsBadRequest() + { + StubValidatorOutcome(isValid: false, propertyName: "Value", message: "Clarification value cannot be empty."); + + var result = await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest(""), + CancellationToken.None); + + result.Should().BeOfType(); + await _pendingClarificationStore.DidNotReceiveWithAnyArgs() + .GetForResolutionAsync(default, default, default); + } + + [Fact] + public async Task ResolveClarification_NotFound_ReturnsNotFound() + { + StubValidatorOutcome(isValid: true); + _pendingClarificationStore + .GetForResolutionAsync(Arg.Any(), UserId, Arg.Any()) + .Returns((PendingClarificationData?)null); + + var result = await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest("{\"frequency_unit\":\"Day\"}"), + CancellationToken.None); + + result.Should().BeOfType(); + await _operationExecutor.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default!, default); + } + + [Fact] + public async Task ResolveClarification_ValueNotOffered_ReturnsBadRequest() + { + StubValidatorOutcome(isValid: true); + StubPendingClarification(allowedValues: ["{\"frequency_unit\":\"Day\"}"]); + + var result = await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest("{\"frequency_unit\":\"InjectedValue\"}"), + CancellationToken.None); + + result.Should().BeOfType(); + await _operationExecutor.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default!, default); + } + + [Fact] + public async Task ResolveClarification_ClaimRaceLost_ReturnsConflict() + { + StubValidatorOutcome(isValid: true); + StubPendingClarification(allowedValues: ["{\"frequency_unit\":\"Day\"}"]); + _pendingClarificationStore + .MarkResolvedAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(false); + + var result = await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest("{\"frequency_unit\":\"Day\"}"), + CancellationToken.None); + + result.Should().BeOfType(); + await _operationExecutor.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default!, default); + } + + [Fact] + public async Task ResolveClarification_SuccessfulPath_DispatchesAndReturnsOk() + { + StubValidatorOutcome(isValid: true); + StubPendingClarification( + toolName: "create_habit", + partialArgs: "{\"title\":\"Morning habit\"}", + allowedValues: ["{\"frequency_unit\":\"Day\",\"frequency_quantity\":1}"]); + _pendingClarificationStore + .MarkResolvedAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(true); + var executorResponse = new AgentExecuteOperationResponse(new AgentOperationResult( + OperationId: "create_habit", + SourceName: "create_habit", + RiskClass: AgentRiskClass.Low, + ConfirmationRequirement: AgentConfirmationRequirement.None, + Status: AgentOperationStatus.Succeeded, + TargetName: "Morning habit")); + _operationExecutor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(executorResponse); + + var result = await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest("{\"frequency_unit\":\"Day\",\"frequency_quantity\":1}"), + CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(executorResponse); + } + + private void StubValidatorOutcome(bool isValid, string? propertyName = null, string? message = null) + { + var validationResult = isValid + ? new FluentValidation.Results.ValidationResult() + : new FluentValidation.Results.ValidationResult( + [new FluentValidation.Results.ValidationFailure(propertyName ?? "Value", message ?? "invalid")]); + _resolveClarificationValidator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(validationResult); + } + + private void StubPendingClarification( + string toolName = "create_habit", + string partialArgs = "{}", + IReadOnlyList? allowedValues = null) + { + var data = new PendingClarificationData( + toolName, + partialArgs, + "frequency_unit", + allowedValues ?? Array.Empty(), + DateTime.UtcNow.AddMinutes(30)); + _pendingClarificationStore + .GetForResolutionAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(data); + } + private void SetUser(bool isApiKey = false) { var claims = new List { new(ClaimTypes.NameIdentifier, UserId.ToString()) };