From 24b9819f17404baa67ac0faee0bb998eab6d1c38 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 17:41:20 -0300 Subject: [PATCH 1/9] fix: resolve Astra habit searches after loading owned records --- .../Chat/HabitNameMatcher.cs | 45 +++++++++++++++++++ .../Tools/Implementations/QueryHabitsTool.cs | 8 ++-- .../AstraHabitQueryRegressionTests.cs | 44 ++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/Orbit.Application/Chat/HabitNameMatcher.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs diff --git a/src/Orbit.Application/Chat/HabitNameMatcher.cs b/src/Orbit.Application/Chat/HabitNameMatcher.cs new file mode 100644 index 000000000..eaea5d150 --- /dev/null +++ b/src/Orbit.Application/Chat/HabitNameMatcher.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using System.Text; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Chat; + +public static class HabitNameMatcher +{ + public static string Normalize(string value) + { + var result = new StringBuilder(); + foreach (var character in value.Normalize(NormalizationForm.FormD)) + { + if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark) + continue; + result.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' '); + } + return string.Join(' ', result.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + public static bool Matches(string title, string search) + { + var normalized = Normalize(search); + if (normalized.Length == 0) + return false; + var candidate = Normalize(title); + if (candidate.Contains(normalized, StringComparison.Ordinal)) + return true; + var words = candidate.Split(' ').Select(CanonicalWord).ToArray(); + return normalized.Split(' ').Select(CanonicalWord) + .All(word => words.Contains(word, StringComparer.Ordinal)); + } + + public static Habit? Resolve(IEnumerable habits, string name) + { + var candidates = habits.ToList(); + var exact = candidates.Where(habit => Normalize(habit.Title) == Normalize(name)).ToList(); + if (exact.Count > 0) + return exact.Count == 1 ? exact[0] : null; + var matches = candidates.Where(habit => Matches(habit.Title, name)).ToList(); + return matches.Count == 1 ? matches[0] : null; + } + + private static string CanonicalWord(string word) => word is "ler" or "leitura" or "lendo" ? "leitura" : word; +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs index 46e981b02..0831b0454 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs @@ -162,9 +162,7 @@ private async Task QueryHabitsAsync( && (f.IsBadHabit == null || h.IsBadHabit == f.IsBadHabit.Value) && (!f.FrequencyOneTime || h.FrequencyUnit == null) && (f.Frequency == null || h.FrequencyUnit == f.Frequency.Value) - && (!f.Date.HasValue || !h.IsGeneral) - && (normalizedSearch == null || h.Title.Contains(normalizedSearch, StringComparison.OrdinalIgnoreCase)) - && (normalizedTag == null || h.Tags.Any(t => t.Name.Contains(normalizedTag, StringComparison.OrdinalIgnoreCase))), + && (!f.Date.HasValue || !h.IsGeneral), q => includeMetrics ? q.Include(h => h.Tags).Include(h => h.Logs).AsSplitQuery() : q @@ -173,6 +171,10 @@ private async Task QueryHabitsAsync( .AsSplitQuery(), ct); + habits = habits.Where(habit => normalizedSearch is null || HabitNameMatcher.Matches(habit.Title, normalizedSearch)) + .Where(habit => normalizedTag is null || habit.Tags.Any(tag => HabitNameMatcher.Matches(tag.Name, normalizedTag))) + .ToList(); + if (f.Date is DateOnly date) { var schedule = await HabitTodaySnapshot.ReadAsync(habits, date, weekStartDay, scheduleLogReader, ct); diff --git a/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs new file mode 100644 index 000000000..a1efd5f62 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Tests.Persistence; + +namespace Orbit.Infrastructure.Tests.Services; + +public class AstraHabitQueryRegressionTests +{ + [Theory] + [InlineData("{\"search\":\"AGUA\"}", "Beber água")] + [InlineData("{\"search\":\"leitura\"}", "Ler 10 minutos")] + [InlineData("{\"tag\":\"saude\"}", "Beber água")] + public async Task QueryHabits_AccountShape_SearchExecutesOnRelationalProvider(string arguments, string expected) + { + using var factory = new SqliteOrbitDbContextFactory(); + var db = factory.Context; + var user = User.Create("Test", "test@example.com").Value; + var today = new DateOnly(2026, 10, 19); + var water = Habit.Create(new HabitCreateParams(user.Id, "Beber água", FrequencyUnit.Day, 1, today)).Value; + var reading = Habit.Create(new HabitCreateParams(user.Id, "Ler 10 minutos", FrequencyUnit.Day, 1, today)).Value; + var tag = Tag.Create(user.Id, "Saúde", "#123456").Value; + water.AddTag(tag); + db.Users.Add(user); + db.Tags.Add(tag); + db.Habits.AddRange(water, reading); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + var dates = Substitute.For(); + dates.GetUserTodayAsync(user.Id, Arg.Any()).Returns(today); + var tool = new QueryHabitsTool(new GenericRepository(db), new GenericRepository(db), + dates, Substitute.For()); + + var result = await tool.ExecuteAsync(JsonDocument.Parse(arguments).RootElement, user.Id, default); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Contain(expected); + } +} From 678d3bb6db150c548156fa145be5f6f732411270 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 17:44:12 -0300 Subject: [PATCH 2/9] fix: stabilize Astra preview bindings and omit empty previews --- .../Chat/AgentArgumentPreview.cs | 2 ++ .../Chat/PendingOperationChangePreviewer.cs | 7 +++-- .../Common/AgentOperationFingerprint.cs | 2 +- .../PendingOperationChangePreviewerTests.cs | 26 ++++++++++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/Orbit.Application/Chat/AgentArgumentPreview.cs b/src/Orbit.Application/Chat/AgentArgumentPreview.cs index e2c5e6642..c2d8adf15 100644 --- a/src/Orbit.Application/Chat/AgentArgumentPreview.cs +++ b/src/Orbit.Application/Chat/AgentArgumentPreview.cs @@ -46,6 +46,7 @@ public static class AgentArgumentPreview var entityId = target?.EntityId ?? Guid.Empty; var entityName = target?.EntityName ?? ReadName(arguments) ?? string.Empty; var fields = arguments.EnumerateObject() + .OrderBy(property => property.Name, StringComparer.Ordinal) .Select(property => new PendingOperationChange( entityId, entityName, @@ -77,6 +78,7 @@ private static bool IsEditable(string field) => { JsonValueKind.Null or JsonValueKind.Undefined => null, JsonValueKind.String => value.GetString(), + JsonValueKind.Object or JsonValueKind.Array => AgentOperationFingerprint.Canonicalize(value.GetRawText()), _ => value.ToString() }; diff --git a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs index b9ef2be8d..8aefe4f3f 100644 --- a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs +++ b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs @@ -397,8 +397,9 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action && habit.TryGetProperty("title", out var title) ? title.ToString() : string.Empty; var fields = habit.ValueKind == JsonValueKind.Object ? habit.EnumerateObject().Where(property => CreateFields.Contains(property.Name)) + .OrderBy(property => property.Name, StringComparer.Ordinal) .Select(property => new PendingOperationChange( - Guid.Empty, name, property.Name, null, property.Value.ToString(), "text", + Guid.Empty, name, property.Name, null, AgentArgumentPreview.FormatValue(property.Value), "text", property.Value.Clone(), true)).ToList() : []; foreach (var error in errors.Where(error => CreateFields.Contains(error.Field))) @@ -415,9 +416,11 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action return BuildPreview("bulk_create_habits", [], items); } - private static PendingOperationChangePreview BuildPreview( + private static PendingOperationChangePreview? BuildPreview( string operationId, IReadOnlyList changes, IReadOnlyList items) { + if (items.Count == 0) + return null; var fingerprint = AgentOperationFingerprint.Compute(operationId, JsonSerializer.Serialize(items)); return new PendingOperationChangePreview(changes, items.Count, items, fingerprint); } diff --git a/src/Orbit.Domain/Common/AgentOperationFingerprint.cs b/src/Orbit.Domain/Common/AgentOperationFingerprint.cs index 9c453f3e5..4e7fc96ce 100644 --- a/src/Orbit.Domain/Common/AgentOperationFingerprint.cs +++ b/src/Orbit.Domain/Common/AgentOperationFingerprint.cs @@ -13,7 +13,7 @@ public static string Compute(string operationIdentity, string argumentsJson) return Convert.ToHexString(bytes); } - private static string Canonicalize(string json) + public static string Canonicalize(string json) { try { diff --git a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs index fa482ab0a..85d32216a 100644 --- a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs +++ b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs @@ -27,6 +27,25 @@ public PendingOperationChangePreviewerTests() => _userDateService.GetUserTodayAsync(UserId, Arg.Any()) .Returns(new DateOnly(2026, 9, 25)); + [Fact] + public async Task PreviewAsync_Create_JsonPropertyOrderDoesNotChangeApproval() + { + var first = await Preview("bulk_create_habits", """{"habits":[{"title":"Academia","frequency_unit":"Day","sub_habits":[{"title":"Treino"}]}]}"""); + var stored = await Preview("bulk_create_habits", """{"habits":[{"sub_habits":[{"title":"Treino"}],"frequency_unit":"Day","title":"Academia"}]}"""); + + stored!.PreviewFingerprint.Should().Be(first!.PreviewFingerprint); + } + + [Fact] + public async Task PreviewAsync_NoMatches_DoesNotOfferEmptyPreview() + { + Setup([]); + + var preview = await Preview("bulk_log_habits", """{"filter":{"search":"meditação"}}"""); + + preview.Should().BeNull(); + } + [Fact] public async Task PreviewAsync_ThreeMatches_UsesEachRealOldEmoji() { @@ -169,7 +188,7 @@ public async Task PreviewAsync_Create_ValidBatchPreservesPreviewAndFingerprint() item.ValidationErrors.Should().BeNull(); preview.Changes.Should().BeEmpty(); preview.ChangeTargetCount.Should().Be(1); - item.Fields.Select(field => field.Field).Should().Equal("title", "frequency_unit", "frequency_quantity"); + item.Fields.Select(field => field.Field).Should().Equal("frequency_quantity", "frequency_unit", "title"); var legacyItemJson = JsonSerializer.SerializeToElement(item); legacyItemJson.EnumerateObject().Select(property => property.Name).Should().Equal( "ItemId", "EntityId", "EntityName", "Fields", "StateFingerprint", "RemovesData"); @@ -247,14 +266,13 @@ public async Task PreviewAsync_Reschedule_ShowsOldAndNewDate() } [Fact] - public async Task PreviewAsync_ZeroMatches_ReturnsEmptyRowsAndZeroTotal() + public async Task PreviewAsync_ZeroMatches_OffersNoPreview() { Setup([]); var preview = await Preview("bulk_update_habits", """{"filter":{"all":true},"updates":{"emoji":"✅"}}"""); - preview!.ChangeTargetCount.Should().Be(0); - preview.Changes.Should().BeEmpty(); + preview.Should().BeNull(); } [Fact] From 38b92abbf729f9cbe244f9f5321a0c9998b6ad52 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 17:57:18 -0300 Subject: [PATCH 3/9] fix: resolve Astra writes and bind one grounded approval per intent --- .../ProcessUserChatCommand.Accumulator.cs | 10 +- .../ProcessUserChatCommand.Resolution.cs | 230 +++++++++++++ .../Commands/ProcessUserChatCommand.Tools.cs | 29 +- .../Chat/Commands/ProcessUserChatCommand.cs | 10 +- .../Chat/Grounding/TurnFactSet.cs | 7 +- .../Chat/PendingOperationChangePreviewer.cs | 119 ++++++- .../Chat/PendingOperationRevisionService.cs | 9 +- .../Tools/Implementations/AssignTagsTool.cs | 62 +++- .../Implementations/BulkCreateHabitsTool.cs | 32 ++ .../Implementations/BulkHabitLogArguments.cs | 64 ++++ .../Implementations/BulkHabitToolArguments.cs | 6 +- .../Implementations/BulkLogHabitsTool.cs | 31 +- .../Habits/Commands/BulkHabitSelection.cs | 2 +- .../Interfaces/IAgentPlatformServices.cs | 1 + src/Orbit.Domain/Models/AgentContracts.cs | 2 + .../Services/AgentOperationExecutor.cs | 17 + .../Services/PendingAgentOperationStore.cs | 26 ++ .../Sections/Static/GlobalRulesSection.cs | 7 +- .../PendingOperationChangePreviewerTests.cs | 19 ++ ...sUserChatCommandHandlerTests.Resolution.cs | 303 ++++++++++++++++++ .../ProcessUserChatCommandHandlerTests.cs | 4 +- .../Services/AgentChatWriteHoldTests.cs | 134 ++++++++ 22 files changed, 1095 insertions(+), 29 deletions(-) create mode 100644 src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs create mode 100644 tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs index 444d4c96c..abc476c54 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs @@ -16,6 +16,13 @@ internal sealed class ToolExecutionAccumulator private readonly HashSet<(string Domain, string Access)> _seenToolSteps = []; private readonly SemaphoreSlim _toolStepGate = new(1, 1); + public IReadOnlyList Habits { get; init; } = []; + public bool RequiresExistingParent { get; init; } + public HashSet<(Guid? ParentId, string Name)> ProposedHabitNames { get; } = []; + public bool IsTaggingIntent { get; init; } + public HashSet MissingHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet PreviewedCalls { get; } = new(StringComparer.Ordinal); + public DateTime? AnsweredAtUtc { get; init; } public List CommittedActions { get; } = []; public List CommittedPendingOperations { get; } = []; @@ -95,7 +102,8 @@ public void Add( if (policyDenial is not null) PolicyDenials.Add(policyDenial); - if (pendingOperation is not null) + if (pendingOperation is not null && PendingOperations.All(item => item.Id != pendingOperation.Id) + && pendingOperation.ChangeTargetCount != 0 && pendingOperation.Items is not { Count: 0 }) PendingOperations.Add(pendingOperation); } diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs new file mode 100644 index 000000000..8ab2b5e1b --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Text.Json.Nodes; +using Orbit.Domain.Models; +using Orbit.Application.Chat.Tools.Implementations; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + private static bool IsTagOnlyIntent(string message) => + Regex.IsMatch(message, @"\b(tags?|etiquetas?)\b", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)) + && !Regex.IsMatch(message, @"\b(crie|criar|create|add|adicione)\s+(?:(?:um|uma|new|a|one|novo|nova)\s+)?(?:h[aá]bitos?|habits?|rotina)\b", + RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + + private void SeedProposedHabitNames(Guid userId, ToolExecutionAccumulator results) + { + foreach (var name in execution.PendingOperationStore.GetPendingHabitCreations(userId)) + results.ProposedHabitNames.Add((name.ParentHabitId, HabitNameMatcher.Normalize(name.Title))); + } + + private static ResponseCards ApplyResolutionToCards(ResponseCards cards, ToolExecutionAccumulator results, string? language) + { + if (results.MissingHabitNames.Count == 0) + return cards; + var message = AddResolutionQuestion(cards.AiMessage, results, language)!; + return cards with + { + AiMessage = message, + Parts = cards.Parts is null ? null : new ChatResponsePart[] { new ChatTextPart(message) } + .Concat(cards.Parts.Where(part => part is not ChatTextPart)).ToArray() + }; + } + + private static AiToolCall? ResolveHabitCall(AiToolCall call, ToolExecutionAccumulator results) + { + if (JsonNode.Parse(call.Args.GetRawText()) is not JsonObject arguments) + return call; + + if (!ResolveHabitIdentifiers(arguments, call.Name, results)) + return null; + + if (arguments["filter"] is JsonObject filter && filter["search"] is JsonValue searchValue + && searchValue.TryGetValue(out var search) + && !results.Habits.Any(habit => HabitNameMatcher.Matches(habit.Title, search))) + { + results.MissingHabitNames.Add(search); + return null; + } + + if (call.Name is "create_habit" or "bulk_create_habits" or "create_sub_habit") + return ResolveHabitCreation(call, arguments, results); + + return call with { Args = JsonSerializer.SerializeToElement(arguments) }; + } + + private static bool ResolveHabitIdentifiers(JsonObject arguments, string operationId, ToolExecutionAccumulator results) + { + foreach (var key in new[] { "habit_id", "parent_habit_id" }) + { + if (arguments[key] is not JsonValue value || !value.TryGetValue(out var name) + || Guid.TryParse(name, out _)) + continue; + var habit = HabitNameMatcher.Resolve(results.Habits, name); + if (habit is null) + { + results.MissingHabitNames.Add(name); + return false; + } + arguments[key] = habit.Id.ToString(); + } + + if (arguments["habit_ids"] is JsonArray names) + { + var ids = new HashSet(); + foreach (var value in names) + { + if (value is not JsonValue nameValue || !nameValue.TryGetValue(out var name)) + return true; + if (Guid.TryParse(name, out var id)) + ids.Add(id); + else if (HabitNameMatcher.Resolve(results.Habits, name) is { } habit) + ids.Add(habit.Id); + else + results.MissingHabitNames.Add(name); + } + if (ids.Count == 0) + return false; + if (operationId == "create_goal" && ids.Count != names.Count) + return false; + arguments["habit_ids"] = new JsonArray(ids.Order().Select(id => (JsonNode?)JsonValue.Create(id.ToString())).ToArray()); + } + + return true; + } + + private static AiToolCall? ResolveHabitCreation(AiToolCall call, JsonObject arguments, ToolExecutionAccumulator results) + { + var items = call.Name == "bulk_create_habits" ? arguments["habits"] as JsonArray : new JsonArray(arguments.DeepClone()); + if (items is null) + return call; + if (items.Count == 1 && items[0] is JsonObject root + && root["title"] is JsonValue titleValue && titleValue.TryGetValue(out var title) + && root["sub_habits"] is JsonArray children + && HabitNameMatcher.Resolve(results.Habits, title) is { } parent) + { + arguments = new JsonObject { ["parent_habit_id"] = parent.Id.ToString(), ["habits"] = children.DeepClone() }; + call = call with { Name = "bulk_create_habits" }; + items = arguments["habits"]!.AsArray(); + } + else if (arguments["parent_habit_id"] is null && results.RequiresExistingParent) + { + results.MissingHabitNames.Add(items.FirstOrDefault()?["title"]?.ToString() ?? string.Empty); + return null; + } + var proposed = NewHabitItems(items, arguments, results); + if (proposed is null) + return call; + if (proposed.Count == 0) + return null; + if (call.Name == "bulk_create_habits") + arguments["habits"] = proposed; + else + arguments = proposed[0]!.AsObject(); + return call with { Args = JsonSerializer.SerializeToElement(arguments) }; + } + + private static JsonArray? NewHabitItems(JsonArray items, JsonObject arguments, ToolExecutionAccumulator results) + { + Guid? parentId = Guid.TryParse(arguments["parent_habit_id"]?.ToString(), out var parsed) ? parsed : null; + var proposed = new JsonArray(); + foreach (var item in items.OfType()) + { + if (item["title"] is not JsonValue value || !value.TryGetValue(out var name) || string.IsNullOrWhiteSpace(name)) + return null; + if (results.Habits.Any(habit => (parentId is null || habit.ParentHabitId == parentId) + && HabitNameMatcher.Matches(habit.Title, name))) + continue; + if (results.IsTaggingIntent) + { + results.MissingHabitNames.Add(name); + continue; + } + if (!results.ProposedHabitNames.Add((parentId, HabitNameMatcher.Normalize(name)))) + continue; + proposed.Add(item.DeepClone()); + } + return proposed; + } + + private async Task CoalesceLogCallsAsync(IReadOnlyList calls, + Dictionary resolvedCalls, Guid userId, CancellationToken ct) + { + var logs = calls.Select(call => resolvedCalls[call.Id]).OfType() + .Where(call => call.Name == "bulk_log_habits").ToList(); + if (logs.Count < 2) + return; + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var call in logs) + { + var expanded = await BulkHabitLogArguments.ExpandAsync(call.Args, data.HabitRepository, + execution.UserDateService, userId, ct); + if (expanded.IsFailure) + return; + foreach (var item in expanded.Value.GetProperty("revised_items").EnumerateArray()) + entries.TryAdd(Orbit.Domain.Common.AgentOperationFingerprint.Compute("log", item.GetRawText()), item.Clone()); + } + resolvedCalls[logs[0].Id] = logs[0] with + { + Args = JsonSerializer.SerializeToElement(new { revised_items = entries.Values.ToArray() }) + }; + foreach (var call in logs.Skip(1)) + resolvedCalls[call.Id] = null; + } + + private static void CoalesceTagCalls(Dictionary calls) + { + var tags = calls.Values.OfType().Where(call => call.Name == "assign_tags") + .GroupBy(call => + { + var arguments = JsonNode.Parse(call.Args.GetRawText())!.AsObject(); + arguments.Remove("habit_id"); + arguments.Remove("habit_ids"); + return Orbit.Domain.Common.AgentOperationFingerprint.Compute("tags", arguments.ToJsonString()); + }); + foreach (var group in tags.Select(group => group.ToList()).ToList()) + { + if (group.Count < 2) + continue; + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var call in group) + { + if (call.Args.TryGetProperty("habit_id", out var id) && id.ValueKind == JsonValueKind.String) + ids.Add(id.GetString()!); + if (call.Args.TryGetProperty("habit_ids", out var values) && values.ValueKind == JsonValueKind.Array) + foreach (var value in values.EnumerateArray()) + ids.Add(value.GetString()!); + } + var first = group[0]; + var arguments = JsonNode.Parse(first.Args.GetRawText())!.AsObject(); + arguments.Remove("habit_id"); + arguments["habit_ids"] = new JsonArray(ids.Order(StringComparer.Ordinal).Select(id => (JsonNode?)JsonValue.Create(id)).ToArray()); + calls[first.Id] = first with { Args = JsonSerializer.SerializeToElement(arguments) }; + foreach (var call in group.Skip(1)) + calls[call.Id] = null; + } + } + + private static ToolCallOutcome ResolvedWithoutWrite(AiToolCall call, ToolExecutionAccumulator results) => new( + new AiToolCallResult(call.Name, call.Id, true, null, null, null, new + { + status = "no_new_preview", + existing_habits = results.Habits.Select(habit => new { habit.Id, habit.Title }).ToArray(), + unresolved_names = results.MissingHabitNames.ToArray(), + instruction = "Reuse existing habits. A repeated create or preview was refused. Ask about unresolved names without creating them." + }), null, null, null, null); + + private static string? AddResolutionQuestion(string? message, ToolExecutionAccumulator results, string? language) + { + if (results.MissingHabitNames.Count == 0) + return message; + var names = string.Join(", ", results.MissingHabitNames); + var question = Orbit.Application.Common.LocaleHelper.IsPortuguese(language) + ? $"Não encontrei estes hábitos: {names}. Qual hábito você quer usar?" + : $"I couldn't find these habits: {names}. Which habit would you like to use?"; + var previewMessage = Orbit.Application.Common.LocaleHelper.IsPortuguese(language) + ? "Revise a prévia antes de aprovar." : "Review the preview before approving."; + return results.PendingOperations.Count == 0 ? question : previewMessage + "\n\n" + question; + } +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs index 1a6d21ddd..0a18992ae 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs @@ -187,6 +187,9 @@ private async Task> ExecuteToolCall .Where(call => call.Name != "ask" && ai.ToolRegistry.GetTool(call.Name)?.IsReadOnly != true) .ToList(); var bulkRedirects = BulkToolRepeatGuard.FindRedirects(writeCalls); + var resolvedWrites = writeCalls.ToDictionary(call => call.Id, call => ResolveHabitCall(call, executionResults)); + await CoalesceLogCallsAsync(writeCalls, resolvedWrites, request.UserId, cancellationToken); + CoalesceTagCalls(resolvedWrites); var readOnlyTasks = readOnlyCalls .Select(call => ExecuteReadOnlyToolCallOnIsolatedScopeAsync(call, request, executionResults, cancellationToken)) @@ -204,10 +207,18 @@ private async Task> ExecuteToolCall outcomesByCallId[call.Id] = RepeatedSingleEntityToolOutcome(call, bulkTool); continue; } - outcomesByCallId[call.Id] = executionResults.AnsweredAtUtc is { } claim - ? await ExecuteBlockWriteAsync(call, request, executionResults, claim, cancellationToken) + var resolved = resolvedWrites[call.Id]; + if (resolved is null || !executionResults.PreviewedCalls.Add( + Orbit.Domain.Common.AgentOperationFingerprint.Compute(resolved.Name, resolved.Args.GetRawText()))) + { + outcomesByCallId[call.Id] = ResolvedWithoutWrite(call, executionResults); + continue; + } + var outcome = executionResults.AnsweredAtUtc is { } claim + ? await ExecuteBlockWriteAsync(resolved, request, executionResults, claim, cancellationToken) : await ExecuteSingleToolCallAsync( - call, request, executionResults, execution.OperationExecutor, execution.PendingClarificationStore, cancellationToken); + resolved, request, executionResults, execution.OperationExecutor, execution.PendingClarificationStore, cancellationToken); + outcomesByCallId[call.Id] = outcome with { ToolResult = outcome.ToolResult with { Name = call.Name } }; } if (orderedCalls.FirstOrDefault(call => call.Name == "ask") is { } askCall) @@ -338,7 +349,17 @@ private async Task ExecuteSingleToolCallAsync( return operationResult.Status switch { AgentOperationStatus.PendingConfirmation => new ToolCallOutcome( - new AiToolCallResult(call.Name, call.Id, false, null, null, "Confirmation required before this action can run."), + new AiToolCallResult(call.Name, call.Id, true, null, null, null, new + { + status = "awaiting_confirmation", + item_count = executionResponse.PendingOperation?.ChangeTargetCount, + habit_count = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?.Select(item => item.EntityId).Distinct().Count() : (int?)null, + date_count = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?.SelectMany(item => item.Fields).Where(field => field.Field == "date").Select(field => field.NewValue).Distinct().Count() : (int?)null, + resolved_habits = executionResults.Habits.Where(habit => call.Args.GetRawText().Contains(habit.Id.ToString(), StringComparison.OrdinalIgnoreCase)).Select(habit => new { habit.Id, habit.Title }).ToArray(), + items = executionResponse.PendingOperation?.Items, + changes = executionResponse.PendingOperation?.Changes, + instruction = "This is a proposal. Nothing has changed until approval. Use only the returned numbers, names, and dates." + }), null, null, executionResponse.PolicyDenial, diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 082887767..1360e73cf 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -262,7 +262,14 @@ private async Task> HandleTurnAsync(ProcessUserChatCommand if (response.IsFailure) return response.PropagateError(); - var executionResults = new ToolExecutionAccumulator { AnsweredAtUtc = answeredAtUtc }; + var executionResults = new ToolExecutionAccumulator + { + AnsweredAtUtc = answeredAtUtc, + Habits = context.ActiveHabits, + IsTaggingIntent = IsTagOnlyIntent(request.Message), + RequiresExistingParent = Regex.IsMatch(request.Message, @"\b(divida|dividir|split|break down)\b", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)) + }; + SeedProposedHabitNames(request.UserId, executionResults); var actionsStopwatch = System.Diagnostics.Stopwatch.StartNew(); var toolLoopResult = await RunToolCallLoopAsync( response.Value, @@ -303,6 +310,7 @@ private async Task> HandleTurnAsync(ProcessUserChatCommand request, context, executionResults, cancellationToken) : await TryBuildResponseCardsAsync( responseText, request, context, executionResults, cancellationToken); + cards = ApplyResolutionToCards(cards, executionResults, userLanguage); var groundedReply = await ApplyGroundingAsync(cards.AiMessage, followUps, executionResults, request, groundingTurn); var aiMessage = groundedReply.Message; followUps = groundedReply.FollowUps; diff --git a/src/Orbit.Application/Chat/Grounding/TurnFactSet.cs b/src/Orbit.Application/Chat/Grounding/TurnFactSet.cs index aa9746cb4..a6d0f9789 100644 --- a/src/Orbit.Application/Chat/Grounding/TurnFactSet.cs +++ b/src/Orbit.Application/Chat/Grounding/TurnFactSet.cs @@ -31,8 +31,11 @@ private static void ReadPayload(JsonElement element, string? property, AiToolCal switch (element.ValueKind) { case JsonValueKind.Object: + var proposedField = element.TryGetProperty("Field", out var field) && field.ValueKind == JsonValueKind.String + ? field.GetString() : null; foreach (var child in element.EnumerateObject()) - ReadPayload(child.Value, child.Name, result, facts); + ReadPayload(child.Value, child.Name == "ProposedValue" && proposedField is not null + ? proposedField : child.Name, result, facts); break; case JsonValueKind.Array: foreach (var child in element.EnumerateArray()) @@ -42,7 +45,7 @@ private static void ReadPayload(JsonElement element, string? property, AiToolCal AddText(element.GetRawText(), false, result, facts); break; case JsonValueKind.String: - AddText(element.GetString(), key is "TITLE" or "NAME" or "HABITTITLE" or "GOALTITLE" or "HABITNAME" or "GOALNAME" or "TAGNAME", result, facts); + AddText(element.GetString(), key is "ENTITYNAME" or "TITLE" or "NAME" or "HABITTITLE" or "GOALTITLE" or "HABITNAME" or "GOALNAME" or "TAGNAME" or "TAGNAMES", result, facts); break; } } diff --git a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs index 8aefe4f3f..b228986a0 100644 --- a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs +++ b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs @@ -36,6 +36,8 @@ public sealed class PendingOperationChangePreviewer( JsonElement arguments, CancellationToken cancellationToken = default) { + if (HasLogDates(operationId, arguments)) + return await PreviewLogDatesAsync(userId, operationId, arguments, cancellationToken); if (operationId == "bulk_create_habits") return await PreviewCreateAsync(userId, arguments, cancellationToken); @@ -43,10 +45,7 @@ public sealed class PendingOperationChangePreviewer( or "bulk_delete_habits" or "bulk_log_habits" or "bulk_skip_habits" or "bulk_update_habit_emojis" or "delete_habit")) { - return destructivePreviewer is not null && destructivePreviewer.Handles(operationId) - ? await destructivePreviewer.PreviewAsync(userId, operationId, arguments, cancellationToken) - : AgentArgumentPreview.Build(operationId, arguments, - await ResolveTargetAsync(userId, arguments, cancellationToken), EditableSchema(operationId)); + return await PreviewOtherAsync(userId, operationId, arguments, cancellationToken); } if (operationId == "bulk_update_habit_emojis" @@ -73,6 +72,8 @@ public sealed class PendingOperationChangePreviewer( var habits = await BulkHabitSelection.LoadAsync(habitRepository, userId, filter, cancellationToken); var today = await userDateService.GetUserTodayAsync(userId, cancellationToken); + if (IsDatedRevision(operationId, isRevised)) + return PreviewDatedItems(operationId, arguments, habits); var rows = new List(); var items = new List(); foreach (var habit in habits) @@ -107,6 +108,106 @@ public sealed class PendingOperationChangePreviewer( return BuildPreview(operationId, rows, items); } + private async Task PreviewOtherAsync(Guid userId, string operationId, + JsonElement arguments, CancellationToken ct) + { + if (destructivePreviewer is not null && destructivePreviewer.Handles(operationId)) + return await destructivePreviewer.PreviewAsync(userId, operationId, arguments, ct); + if (operationId == "assign_tags" && arguments.TryGetProperty("habit_ids", out _)) + { + var singles = AssignTagsTool.SingleHabitArguments(arguments); + if (singles is null) + return null; + var items = new List(); + foreach (var single in singles) + { + var preview = await PreviewOtherAsync(userId, operationId, single, ct); + if (preview?.Items is not { Count: 1 } || preview.Items[0].EntityId is null) + return null; + items.Add(preview.Items[0] with + { + Fields = preview.Items[0].Fields.Select(field => field with { IsEditable = false }).ToList() + }); + } + return BuildPreview(operationId, items.Take(10).SelectMany(item => item.Fields).ToList(), items); + } + var built = AgentArgumentPreview.Build(operationId, arguments, + await ResolveTargetAsync(userId, arguments, ct), EditableSchema(operationId)); + return await BindDependenciesAsync(userId, operationId, arguments, built, ct); + } + + private async Task BindDependenciesAsync(Guid userId, string operationId, + JsonElement arguments, PendingOperationChangePreview? preview, CancellationToken ct) + { + if (preview?.Items is null) + return preview; + var habitIds = DependencyIds(arguments, "habit_ids", "parent_habit_id"); + var tagIds = DependencyIds(arguments, "tag_ids"); + if (habitIds.Count == 0 && tagIds.Count == 0) + return preview; + var habits = habitIds.Count == 0 ? [] : await habitRepository.FindAsync( + habit => habit.UserId == userId && habitIds.Contains(habit.Id), ct); + var tags = tagIds.Count == 0 ? [] : await tagRepository.FindAsync( + tag => tag.UserId == userId && tagIds.Contains(tag.Id), ct); + if (habits.Count != habitIds.Count || tags.Count != tagIds.Count) + return null; + var states = habits.OrderBy(habit => habit.Id).Select(FingerprintHabit) + .Concat(tags.OrderBy(tag => tag.Id).Select(FingerprintTag)).ToArray(); + var items = preview.Items.Select(item => item with + { + StateFingerprint = AgentOperationFingerprint.Compute(item.StateFingerprint, JsonSerializer.Serialize(states)) + }).ToList(); + return BuildPreview(operationId, preview.Changes, items); + } + + private static HashSet DependencyIds(JsonElement arguments, params string[] fields) + { + var result = new HashSet(); + foreach (var field in fields) + { + if (!arguments.TryGetProperty(field, out var value)) + continue; + var values = value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToArray() : [value]; + foreach (var candidate in values) + if (candidate.ValueKind == JsonValueKind.String && Guid.TryParse(candidate.GetString(), out var id)) + result.Add(id); + } + return result; + } + + private static bool HasLogDates(string operationId, JsonElement arguments) => + operationId == "bulk_log_habits" && arguments.TryGetProperty("dates", out _); + + private async Task PreviewLogDatesAsync(Guid userId, string operationId, + JsonElement arguments, CancellationToken ct) + { + var expanded = await BulkHabitLogArguments.ExpandAsync(arguments, habitRepository, userDateService, userId, ct); + return expanded.IsSuccess ? await PreviewAsync(userId, operationId, expanded.Value, ct) : null; + } + + private static bool IsDatedRevision(string operationId, bool revised) => + revised && operationId is "bulk_log_habits" or "bulk_skip_habits"; + + private static PendingOperationChangePreview? PreviewDatedItems(string operationId, + JsonElement arguments, IReadOnlyList habits) + { + var (datedItems, _) = BulkHabitToolArguments.ParseRevisedDatedItems(arguments); + var byId = habits.ToDictionary(habit => habit.Id); + if (datedItems is null || datedItems.Any(item => !byId.ContainsKey(item.HabitId))) + return null; + var multipleDates = datedItems.GroupBy(item => item.HabitId).Any(group => group.Count() > 1); + var datedPreview = datedItems.OrderBy(item => item.HabitId).ThenBy(item => item.Date).Select(item => + { + var habit = byId[item.HabitId]; + var date = item.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var change = new PendingOperationChange(habit.Id, habit.Title, "date", null, date, + "date", JsonSerializer.SerializeToElement(date), true); + return new PendingOperationItem(multipleDates ? $"{habit.Id}:{date}" : habit.Id.ToString(), + habit.Id, habit.Title, [change], FingerprintHabit(habit), RemovesData: false); + }).ToList(); + return BuildPreview(operationId, datedPreview.Take(10).SelectMany(item => item.Fields).ToList(), datedPreview); + } + /// /// The parameter schema of a tool that checks its own arguments. A tool with no check offers /// no editable field, because the revise route could not hold an edit to the tool's rules. @@ -372,7 +473,7 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action FingerprintHabit(habit), RemovesData: true)).ToList()); } - private static async Task PreviewCreateAsync( + private async Task PreviewCreateAsync( Guid userId, JsonElement arguments, CancellationToken cancellationToken) { if (!arguments.TryGetProperty("habits", out var habits) || habits.ValueKind != JsonValueKind.Array) @@ -383,6 +484,12 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action : null) .Select(item => item ?? new BulkHabitItem(string.Empty, null, null, null)).ToList()); var validation = await new BulkCreateHabitsCommandValidator().ValidateAsync(command, cancellationToken); + var parent = TryReadId(arguments, "parent_habit_id", out var parentId) + ? await habitRepository.FindAsync(habit => habit.UserId == userId && habit.Id == parentId, cancellationToken) + : []; + if (arguments.TryGetProperty("parent_habit_id", out _) && parent.Count != 1) + return null; + var parentFingerprint = parent.Count == 1 ? FingerprintHabit(parent[0]) : string.Empty; var items = habits.EnumerateArray().Select((habit, index) => { var prefix = $"Habits[{index}]."; @@ -410,7 +517,7 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action && habit.TryGetProperty("preview_item_id", out var storedId) ? storedId.ToString() : index.ToString(CultureInfo.InvariantCulture); return new PendingOperationItem(itemId, null, - name, fields, AgentOperationFingerprint.Compute("bulk_create_habits", habit.GetRawText()), + name, fields, AgentOperationFingerprint.Compute("bulk_create_habits" + parentFingerprint, habit.GetRawText()), RemovesData: false, ValidationErrors: errors.Count > 0 ? errors : null); }).ToList(); return BuildPreview("bulk_create_habits", [], items); diff --git a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs index 98dca622e..3a03e742e 100644 --- a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs +++ b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs @@ -211,6 +211,13 @@ private static bool BuildHabitArguments(JsonObject root, string operationId, private static bool BuildOtherArguments(JsonObject root, string operationId, IReadOnlyList selected) { + if (operationId == "assign_tags" && root.ContainsKey("habit_ids")) + { + if (selected.Any(HasEdits)) + return false; + root["habit_ids"] = new JsonArray(selected.Select(item => (JsonNode?)JsonValue.Create(item.ItemId)).ToArray()); + return true; + } if (operationId is "delete_goal" or "delete_tag" or "delete_checklist_template") return selected.Count == 1 && !HasEdits(selected[0]); if (operationId == "manage_calendar_sync") @@ -331,7 +338,7 @@ private static bool BuildDatedHabitArguments(JsonObject root, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _)) return false; - items.Add(new JsonObject { ["habit_id"] = item.ItemId, ["date"] = date }); + items.Add(new JsonObject { ["habit_id"] = offered[item.ItemId].EntityId?.ToString(), ["date"] = date }); } root.Remove("filter"); root.Remove("habit_ids"); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs index 1e82e4432..564d72bb9 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.EntityFrameworkCore; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -8,7 +9,7 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class AssignTagsTool( IGenericRepository habitRepository, IGenericRepository tagRepository, - IUnitOfWork unitOfWork) : IAiTool, IArgumentCheckTool + IUnitOfWork unitOfWork) : IAiTool, IArgumentCheckTool, ITransactionalAiTool { public string Name => "assign_tags"; @@ -23,6 +24,7 @@ public class AssignTagsTool( properties = new { habit_id = new { type = JsonSchemaTypes.String, description = "ID of the habit to tag" }, + habit_ids = new { type = JsonSchemaTypes.Array, items = new { type = JsonSchemaTypes.String }, description = "IDs of all existing habits to tag in one approval. Use instead of habit_id for multiple habits." }, tag_names = new { type = JsonSchemaTypes.Array, @@ -36,11 +38,24 @@ public class AssignTagsTool( items = new { type = JsonSchemaTypes.String } } }, - required = new[] { "habit_id" } + required = Array.Empty() }; public async Task CheckArgumentsAsync(JsonElement args, Guid userId, CancellationToken ct) { + if (args.TryGetProperty("habit_ids", out _)) + { + var batch = SingleHabitArguments(args); + if (batch is null) + return Orbit.Domain.Common.Result.Failure("Provide a non-empty habit_ids array without habit_id."); + foreach (var arguments in batch) + { + var checkedItem = await CheckArgumentsAsync(arguments, userId, ct); + if (checkedItem.IsFailure) + return checkedItem; + } + return Orbit.Domain.Common.Result.Success(); + } if (!HabitToolHelpers.TryParseHabitId(args, out var habitId)) return Orbit.Domain.Common.Result.Failure("Invalid habit_id."); var habits = await habitRepository.FindAsync(h => h.Id == habitId && h.UserId == userId, ct); @@ -81,6 +96,9 @@ public class AssignTagsTool( public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) { + if (args.TryGetProperty("habit_ids", out _)) + return await AssignBatchAsync(args, userId, ct); + if (!args.TryGetProperty("habit_id", out var habitIdEl) || !Guid.TryParse(habitIdEl.GetString(), out var habitId)) return new ToolResult(false, Error: "habit_id is required and must be a valid GUID."); @@ -94,6 +112,46 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel return new ToolResult(false, Error: "Provide either tag_ids or tag_names."); } + private async Task AssignBatchAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var validation = await CheckArgumentsAsync(args, userId, ct); + if (validation.IsFailure) + return ToolResult.FromFailure(validation); + var results = new List(); + foreach (var arguments in SingleHabitArguments(args)!) + { + var result = await ExecuteAsync(arguments, userId, ct); + if (!result.Success) + return result; + results.Add(result); + } + return new ToolResult(true, EntityName: $"{results.Count} habits tagged", Payload: new + { + habit_count = results.Count, + habits = results.Select(result => new { id = result.EntityId, title = result.EntityName }).ToArray() + }); + } + + internal static IReadOnlyList? SingleHabitArguments(JsonElement args) + { + if (args.TryGetProperty("habit_id", out _) || !args.TryGetProperty("habit_ids", out var ids) + || ids.ValueKind != JsonValueKind.Array || ids.GetArrayLength() == 0 + || ids.GetArrayLength() > Orbit.Application.Common.AppConstants.MaxBulkOperationSize) + return null; + var results = new List(); + var seen = new HashSet(); + foreach (var value in ids.EnumerateArray()) + { + if (value.ValueKind != JsonValueKind.String || !Guid.TryParse(value.GetString(), out var id) || !seen.Add(id)) + return null; + var single = JsonNode.Parse(args.GetRawText())!.AsObject(); + single.Remove("habit_ids"); + single["habit_id"] = id.ToString(); + results.Add(JsonSerializer.SerializeToElement(single)); + } + return results; + } + private async Task AssignByIdsAsync(Guid habitId, JsonElement args, Guid userId, CancellationToken ct) { var idList = JsonArgumentParser.ParseGuidArray(args, "tag_ids") ?? new List(); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs index d4b2cb5b4..a444fe7ef 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs @@ -20,6 +20,7 @@ public class BulkCreateHabitsTool( type = JsonSchemaTypes.Object, properties = new { + parent_habit_id = new { type = JsonSchemaTypes.String, description = "Existing parent habit ID. When supplied, create the items as children of this habit, never create the parent again." }, habits = new { type = JsonSchemaTypes.Array, @@ -60,6 +61,37 @@ private async Task ExecuteCoreAsync(JsonElement args, Guid userId, C if (items.Count == 0) return new ToolResult(false, Error: "No habits provided."); + if (args.TryGetProperty("parent_habit_id", out var parentValue)) + { + if (parentValue.ValueKind != JsonValueKind.String || !Guid.TryParse(parentValue.GetString(), out var parentId)) + return new ToolResult(false, Error: "parent_habit_id must be a valid habit ID."); + if (items.Any(item => item.SubHabits is { Count: > 0 })) + return new ToolResult(false, Error: "Create children directly under the existing parent; nested sub_habits are not supported in this call."); + var created = new List(); + foreach (var item in items) + { + var childCommand = new CreateSubHabitCommand(userId, parentId, item.Title, item.Description, + item.FrequencyUnit, item.FrequencyQuantity, item.IsBadHabit, item.DueDate, + new HabitCommandOptions(Days: item.Days, DueTime: item.DueTime, IsFlexible: item.IsFlexible, + ChecklistItems: item.ChecklistItems), Emoji: item.Emoji, InheritParentFrequency: true, + IntervalWeeks: item.IntervalWeeks); + if (checkOnly) + { + var checkedItem = await ChatToolArgumentCheck.CheckCommandAsync(mediator, childCommand, ct); + if (!checkedItem.Success) + return checkedItem; + } + else + { + var childResult = await mediator.Send(childCommand, ct); + if (childResult.IsFailure) + return ToolResult.FromFailure(childResult); + created.Add(new BulkCreateItemResult(created.Count, BulkItemStatus.Success, childResult.Value, item.Title)); + } + } + return new ToolResult(true, EntityName: $"{items.Count} sub-habits", Payload: new BulkCreateResult(created)); + } + var command = new BulkCreateHabitsCommand(userId, items); if (checkOnly) return await ChatToolArgumentCheck.CheckCommandAsync(mediator, command, ct); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs new file mode 100644 index 000000000..adbb995df --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Text.Json; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Chat.Tools.Implementations; + +internal static class BulkHabitLogArguments +{ + public static async Task> ExpandAsync(JsonElement arguments, + IGenericRepository habits, IUserDateService dates, Guid userId, CancellationToken ct) + { + if (arguments.TryGetProperty("revised_items", out _)) + { + var (_, error) = BulkHabitToolArguments.ParseRevisedDatedItems(arguments); + return error is null ? Result.Success(arguments) : Result.Failure(error); + } + var (filter, filterError) = BulkHabitToolArguments.ParseActionFilter(arguments); + if (filterError is not null) + return Result.Failure(filterError); + var selectedDates = new HashSet(); + if (arguments.TryGetProperty("dates", out var dateArray)) + { + if (arguments.TryGetProperty("date", out _) || dateArray.ValueKind != JsonValueKind.Array + || dateArray.GetArrayLength() == 0) + return Result.Failure("Provide dates as a non-empty array, without date."); + foreach (var date in dateArray.EnumerateArray()) + { + if (!TryDate(date, out var parsed)) + return Result.Failure("Every date must use YYYY-MM-DD format."); + selectedDates.Add(parsed); + } + } + else if (arguments.TryGetProperty("date", out var date) && date.ValueKind != JsonValueKind.Null) + { + if (!TryDate(date, out var parsed)) + return Result.Failure("date must use YYYY-MM-DD format."); + selectedDates.Add(parsed); + } + else + selectedDates.Add(await dates.GetUserTodayAsync(userId, ct)); + + var selectedHabits = await BulkHabitSelection.LoadAsync(habits, userId, filter!, ct); + if (selectedHabits.Count == 0) + return Result.Failure("No matching habits found."); + return Result.Success(JsonSerializer.SerializeToElement(new + { + revised_items = selectedHabits.SelectMany(habit => selectedDates.Order().Select(date => new + { + habit_id = habit.Id, + date = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + })).ToArray() + })); + } + + private static bool TryDate(JsonElement value, out DateOnly date) + { + date = default; + return value.ValueKind == JsonValueKind.String && DateOnly.TryParseExact(value.GetString(), + "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs index 8542f7e10..5a5dca4a8 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs @@ -14,18 +14,18 @@ public static (IReadOnlyList<(Guid HabitId, DateOnly Date)>? Items, string? Erro || values.ValueKind != JsonValueKind.Array || values.GetArrayLength() == 0) return (null, "revised_items must be a non-empty array."); var items = new List<(Guid HabitId, DateOnly Date)>(); - var seen = new HashSet(); + var seen = new HashSet<(Guid, DateOnly)>(); foreach (var value in values.EnumerateArray()) { if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty("habit_id", out var idValue) || idValue.ValueKind != JsonValueKind.String || !Guid.TryParse(idValue.GetString(), out var id) - || !seen.Add(id) || !value.TryGetProperty("date", out var dateValue) || dateValue.ValueKind != JsonValueKind.String || !DateOnly.TryParseExact(dateValue.GetString(), "yyyy-MM-dd", - CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + || !seen.Add((id, date))) return (null, "revised_items contains an invalid habit or date."); items.Add((id, date)); } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs index cfea1ac1d..7f1b5707e 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using MediatR; using Orbit.Application.Habits.Commands; using Orbit.Domain.Entities; @@ -17,12 +18,29 @@ public sealed class BulkLogHabitsTool( public string Description => "Log multiple habits from the complete server-side set matching a filter in one operation. Use only for habits the user explicitly described completing. Reports applied, matched, skipped, and partial counts."; - public object GetParameterSchema() => BulkHabitToolArguments.ActionFilterSchema( - "Legacy array of habit IDs to log as completed.", - "Date to log for in YYYY-MM-DD format. Defaults to today."); + public object GetParameterSchema() + { + var schema = JsonSerializer.SerializeToNode(BulkHabitToolArguments.ActionFilterSchema( + "Habit IDs or names to resolve against existing habits. Missing names are reported, never created.", + "Date to log for in YYYY-MM-DD format. Defaults to today."))!.AsObject(); + schema["properties"]!["dates"] = JsonSerializer.SerializeToNode(new + { + type = JsonSchemaTypes.Array, + items = new { type = JsonSchemaTypes.String }, + description = "All requested dates in YYYY-MM-DD format. Use one call for a date range, omitting excluded dates." + }); + return schema; + } public async Task CheckArgumentsAsync(JsonElement args, Guid userId, CancellationToken ct) { + if (args.TryGetProperty("dates", out _)) + { + var expanded = await BulkHabitLogArguments.ExpandAsync(args, habitRepository, userDateService, userId, ct); + if (expanded.IsFailure) + return expanded; + args = expanded.Value; + } IReadOnlyList items; if (args.TryGetProperty("revised_items", out _)) { @@ -53,6 +71,13 @@ public object GetParameterSchema() => BulkHabitToolArguments.ActionFilterSchema( public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) { + if (args.TryGetProperty("dates", out _)) + { + var expanded = await BulkHabitLogArguments.ExpandAsync(args, habitRepository, userDateService, userId, ct); + if (expanded.IsFailure) + return ToolResult.FromFailure(expanded); + args = expanded.Value; + } if (args.TryGetProperty("revised_items", out _)) { var (revised, revisedError) = BulkHabitToolArguments.ParseRevisedDatedItems(args); diff --git a/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs b/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs index c52da0b1d..4a07bcaaf 100644 --- a/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs +++ b/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs @@ -47,7 +47,7 @@ public static async Task> LoadAsync( .Where(habit => string.IsNullOrWhiteSpace(tag) || habit.Tags.Any(candidate => candidate.Name.Equals(tag, StringComparison.OrdinalIgnoreCase))) .Where(habit => string.IsNullOrWhiteSpace(search) - || habit.Title.Contains(search, StringComparison.OrdinalIgnoreCase) + || Orbit.Application.Chat.HabitNameMatcher.Matches(habit.Title, search) || habit.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) .Where(habit => filter.IsGeneral is null || habit.IsGeneral == filter.IsGeneral) .Where(habit => filter.IsBadHabit is null || habit.IsBadHabit == filter.IsBadHabit) diff --git a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs index a39befb2b..b4266e980 100644 --- a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs +++ b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs @@ -48,6 +48,7 @@ PendingAgentOperation Create( PendingAgentOperationConfirmation? Confirm(Guid userId, Guid pendingOperationId); PendingAgentOperationExecution? GetExecution(Guid userId, Guid pendingOperationId); bool IsAwaitingConfirmation(Guid userId, Guid pendingOperationId); + IReadOnlyList GetPendingHabitCreations(Guid userId); bool Revise(Guid userId, Guid pendingOperationId, string expectedFingerprint, string argumentsJson, string operationFingerprint, string previewFingerprint); bool Cancel(Guid userId, Guid pendingOperationId, string expectedFingerprint); diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index b3892122f..b3381d59f 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -481,3 +481,5 @@ public static class AgentScopes ReadSubscriptions ]; } + +public sealed record PendingHabitCreation(string Title, Guid? ParentHabitId = null); diff --git a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs index dd1df7b61..28e0529e6 100644 --- a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs +++ b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs @@ -214,6 +214,19 @@ await TryAuditAsync( var preview = await changePreviewer.PreviewSafelyAsync( execution.Request.UserId, execution.Operation.Id, execution.Arguments, logger, cancellationToken); + if (preview is { ChangeTargetCount: 0 } || preview?.Items is { Count: 0 } + || preview is null && RequiresMatchedHabits(execution.Operation.Id)) + { + pendingOperationStore.Cancel(execution.Request.UserId, pending.Id, + AgentOperationFingerprint.Compute(execution.Operation.Id, execution.Arguments.GetRawText())); + return await BuildToolOutcomeResponseAsync(execution, policyDecision, new ToolResult(true, + Payload: new + { + status = "no_matching_habits", + instruction = "No approval preview was created. Tell the person which habit names did not resolve and ask which existing habits to use. Do not create habits." + }), cancellationToken); + } + if (preview is not null) { if (!string.IsNullOrWhiteSpace(preview.PreviewFingerprint)) @@ -244,6 +257,10 @@ await TryAuditAsync( pending); } + private static bool RequiresMatchedHabits(string operationId) => operationId is + "bulk_log_habits" or "bulk_skip_habits" or "bulk_update_habits" or "bulk_reschedule_habits" + or "bulk_delete_habits" or "bulk_create_habits"; + private async Task ExecuteWithPolicyAsync( OperationExecutionContext execution, CancellationToken cancellationToken) diff --git a/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs b/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs index e236da0d9..4255e3c26 100644 --- a/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs +++ b/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs @@ -103,6 +103,32 @@ public PendingAgentOperation Create( entity.PreviewFingerprint); } + public IReadOnlyList GetPendingHabitCreations(Guid userId) + { + var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; + var proposals = dbContext.PendingAgentOperations.AsNoTracking() + .Where(item => item.UserId == userId && item.ConsumedAtUtc == null && item.ExpiresAtUtc > checkedAtUtc + && (item.OperationId == "create_habit" || item.OperationId == "bulk_create_habits" || item.OperationId == "create_sub_habit")) + .Select(item => item.ArgumentsJson).ToList(); + var names = new List(); + foreach (var json in proposals) + { + var arguments = ParseArguments(json); + Guid? parentId = arguments.TryGetProperty("parent_habit_id", out var parent) + && parent.ValueKind == JsonValueKind.String && Guid.TryParse(parent.GetString(), out var parsedParent) + ? parsedParent : null; + if (arguments.TryGetProperty("habits", out var habits) && habits.ValueKind == JsonValueKind.Array) + { + foreach (var habit in habits.EnumerateArray()) + if (habit.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) + names.Add(new PendingHabitCreation(title.GetString()!, parentId)); + } + else if (arguments.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) + names.Add(new PendingHabitCreation(title.GetString()!, parentId)); + } + return names; + } + public bool IsAwaitingConfirmation(Guid userId, Guid pendingOperationId) { var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs index cd5cd0fd7..00f0db4b0 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs @@ -18,7 +18,7 @@ public string Build(PromptContext context) 1. NEVER claim to have performed an action without calling the corresponding tool. If you want to create, update, delete, log, skip, complete, abandon, or link a habit or goal, you MUST call the tool. Never just say "I've done it" in text. 2. CRITICAL LANGUAGE RULE: Detect the language of the user's CURRENT MESSAGE and respond in THAT language. If the user writes in English, respond in English. If they write in Portuguese, respond in Portuguese. The user's message language ALWAYS wins over any other context. 3. Default dates to TODAY when not specified. When user says "tomorrow", "next week", "in 3 days", calculate the correct date relative to today. - 4. DUPLICATE PREVENTION: Before creating a habit or goal, silently check the indexes already provided above. Those indexes are for ACTIVE duplicate checks. A COMPLETED parent may still appear only to preserve the path to an active sub-habit, but COMPLETED entries never block creating a new habit/goal with the same or similar name. If a very similar ACTIVE habit or goal already exists, mention it briefly and ask if they want a new one or to update the existing. If no ACTIVE duplicate exists, create it immediately. NEVER ask the user for permission to check for duplicates - you already have the list. + 4. DUPLICATE PREVENTION: Before creating a habit or goal, silently check the indexes already provided above. Those indexes are for ACTIVE duplicate checks. A COMPLETED parent may still appear only to preserve the path to an active sub-habit, but COMPLETED entries never block creating a new habit/goal with the same or similar name. If an ACTIVE habit matches the requested name ignoring case, accents, or a partial activity name, use that existing habit for logs, links, tags, and breakdowns. For example, água matches Beber água and leitura matches Ler 10 minutos. Propose a create only when no active match exists and creation was requested. Never repeat a create already proposed in this conversation. For a breakdown, use bulk_create_habits with parent_habit_id and the children in habits; if the parent does not exist, ask which habit to divide. NEVER ask the user for permission to check for duplicates - you already have the list. 5. ACTION-ORIENTED: When the user's intent AND structure are clear (e.g., "I need to meditate every day" or "rename my marathon goal"), act immediately by calling the appropriate tool. Do NOT ask for permission. If the structure or schedule is ambiguous (unknown days for a weekly habit, unspecified time, checklist-vs-subhabit unclear, multi-step routine with no clear grouping), ask ONE targeted question first, then act. Never ask more than one question at a time. See the Structuring Strategy section for when to use checklist_items vs sub_habits. 6. SUB-HABIT AMBIGUITY: If both a parent and sub-habit match, prefer the standalone habit. 7. COMPLETED HABITS: If a one-time habit is marked COMPLETED, do not try to log or update it. If the user asks to create it again, create a NEW habit instead of reusing the completed one. @@ -35,13 +35,14 @@ 9. NEVER expose internal habit IDs (GUIDs) to the user in your messages. Refer t 18. NO HABIT SUBSTITUTION FOR LOG / SKIP. When the user describes an activity ("I meditated", "fiz yoga", "log my workout", "pulei o treino"): - Only call log_habit, bulk_log_habits, skip_habit, or bulk_skip_habits on habits whose title clearly matches the described activity. Obvious translations are fine ("meditei" -> "Meditate" / "Meditar"). - Do NOT log or skip a habit just because it shares a tag, parent, time-of-day, routine, or general theme with the described activity. - - If NO habit in the index clearly matches, do NOT substitute a related habit. Tell the user briefly that you don't see a matching habit and ask if they want to create one. + - If NO habit in the index clearly matches, do NOT substitute a related habit. Tell the user which names did not resolve and ask which existing habit to use, in one reply without an empty preview. - When the user describes multiple activities, log exactly the habits they described - no more, no fewer. - This rule restricts SUBSTITUTION ONLY. Indirect references like "log that one", "mark the first one done", "skip it", or "complete it" after you have already named a specific habit are still valid - resolve them to the habit you were just discussing, then act. 19. EXPLAIN MECHANICS WITH describe_feature. When the user asks how an Orbit mechanic actually works (streaks, freezes, frequencies, XP/levels/achievements, free-vs-pro limits, reminders/notifications, schedule/overdue rules), call describe_feature with the matching feature_key and base your answer on what it returns instead of guessing. 20. BULK ACTIONS: A request affecting multiple habits must use one matching bulk tool. Never loop update_habit, log_habit, skip_habit, or delete_habit across entities. Use a server-side filter for all, tag, text, status, or frequency scopes. For rescheduling use bulk_reschedule_habits. For other field or schedule changes use bulk_update_habits. 21. COMPLETENESS: Never say all, every, complete, or fully done unless the bulk tool reports partial=false and applied_count equals total_matched. State applied_count, total_matched, and skipped_count when they are returned. If partial=true, explicitly say the operation was partial and do not imply the remainder changed. - 22. COPYABLE CONTENT: In English and Portuguese replies, use one fenced block (three backticks: ```, no language tag needed) only for code, a command, or ready-to-paste text (a message, list, or template) the user will copy and use elsewhere. Keep everything else in normal prose; never wrap your whole reply, a single word, or an explanation in a block. + 22. APPROVAL FACTS: awaiting_confirmation tool results describe a proposal, never a completed write. Use only their returned item_count, items, changes, numbers and dates in the reply. Do not repeat a held tool call. For logging across dates, send one bulk_log_habits call with habit_ids and dates containing only the requested dates, excluding exceptions. When some names are missing, preview only the matches and ask about the missing names once. For a goal with habit links, pass habit_ids in create_goal. + 23. COPYABLE CONTENT: In English and Portuguese replies, use one fenced block (three backticks: ```, no language tag needed) only for code, a command, or ready-to-paste text (a message, list, or template) the user will copy and use elsewhere. Keep everything else in normal prose; never wrap your whole reply, a single word, or an explanation in a block. """); return sb.ToString(); } diff --git a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs index 85d32216a..d79e36214 100644 --- a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs +++ b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs @@ -46,6 +46,25 @@ public async Task PreviewAsync_NoMatches_DoesNotOfferEmptyPreview() preview.Should().BeNull(); } + [Fact] + public async Task PreviewAsync_GoalLink_OnlyLinkedRecordChangesInvalidateApproval() + { + var linked = CreateHabit("Ler 10 minutos", null); + var unrelated = CreateHabit("Beber água", null); + var habits = new[] { linked, unrelated }; + _habits.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(call => habits.Where(call.Arg>>().Compile()).ToList()); + var arguments = JsonSerializer.Serialize(new { title = "Ler livros", habit_ids = new[] { linked.Id } }); + var initial = await Preview("create_goal", arguments); + unrelated.PostponeTo(new DateOnly(2026, 9, 26)); + var afterUnrelated = await Preview("create_goal", arguments); + linked.PostponeTo(new DateOnly(2026, 9, 27)); + var afterLinked = await Preview("create_goal", arguments); + + afterUnrelated!.PreviewFingerprint.Should().Be(initial!.PreviewFingerprint); + afterLinked!.PreviewFingerprint.Should().NotBe(initial.PreviewFingerprint); + } + [Fact] public async Task PreviewAsync_ThreeMatches_UsesEachRealOldEmoji() { diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs new file mode 100644 index 000000000..d2a6b78fb --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs @@ -0,0 +1,303 @@ +using System.Linq.Expressions; +using System.Text.Json; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Chat; +using Orbit.Application.Chat.Commands; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Application.Tests.Commands.Chat; + +public partial class ProcessUserChatCommandHandlerTests +{ + [Fact] + public async Task Replay_GoalWithReadingLink_ResolvesAndCreatesLinkedGoal() + { + var habits = SetupResolutionAccount(); + var goal = new CreateGoalTool(_goalRepo, _unitOfWork, _habitRepo); + var handler = CreateHandler(goal); + var held = HoldReplayWrites(goal); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("create_goal", """{"title":"Ler 12 livros","target_value":12,"unit":"livros","deadline":"2026-12-31","habit_ids":["leitura"]}""")); + ReplyToReplay("Revise a meta antes de aprovar."); + + var result = await handler.Handle(new(UserId, "Crie uma meta de ler 12 livros até dezembro e ligue ao hábito de leitura."), default); + + result.Value.PendingOperations.Should().ContainSingle(); + held.Should().ContainSingle().Which.Arguments.GetProperty("habit_ids")[0].GetString().Should().Be(habits[1].Id.ToString()); + (await goal.ExecuteAsync(held[0].Arguments, UserId, default)).Success.Should().BeTrue(); + await _goalRepo.Received().AddAsync(Arg.Is(value => value.Habits.Single().Id == habits[1].Id), Arg.Any()); + } + + [Fact] + public async Task Replay_LogsMatchingHabitsOnSixDates_OnePreviewAndMissingNameQuestion() + { + var habits = SetupResolutionAccount(); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var handler = CreateHandler(log); + var held = HoldReplayWrites(log); + const string arguments = """{"habit_ids":["água","leitura","meditação"],"dates":["2026-03-28","2026-03-29","2026-03-30","2026-03-31","2026-04-01","2026-04-03"]}"""; + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result.Success(new AiResponse { ToolCalls = [new("bulk_log_habits", "log1", ParseArguments(arguments)), new("bulk_log_habits", "log2", ParseArguments(arguments))], ConversationContext = new() })); + ReplyToReplay("Revise os registros."); + + var result = await handler.Handle(new(UserId, "Registre água, leitura e meditação nos últimos 7 dias, menos ontem."), default); + + result.Value.PendingOperations.Should().ContainSingle().Which.Items.Should().HaveCount(12); + result.Value.AiMessage.Should().Contain("meditação").And.Contain("?"); + held.Should().ContainSingle(); + var items = held[0].Arguments.GetProperty("revised_items").EnumerateArray().ToArray(); + items.Select(item => item.GetProperty("habit_id").GetString()).Distinct().Should().BeEquivalentTo(habits.Take(2).Select(habit => habit.Id.ToString())); + items.Select(item => item.GetProperty("date").GetString()).Distinct().Should().HaveCount(6); + } + + [Fact] + public async Task Replay_BreakdownUsesExistingGym_ApprovalCreatesChildren() + { + var habits = SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var single = new CreateHabitTool(_habitRepo, _tagRepo, _goalRepo, _userDateService, _payGate, _unitOfWork); + var handler = CreateHandler(bulk, single); + var held = HoldReplayWrites(bulk, single); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("create_habit", """{"title":"academia","sub_habits":[{"title":"Aquecimento"},{"title":"Treino"},{"title":"Alongamento"}]}""")); + ReplyToReplay("Revise a divisão."); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(Result.Success(Guid.NewGuid())); + + var result = await handler.Handle(new(UserId, "Divida a academia em aquecimento, treino e alongamento."), default); + + result.Value.PendingOperations.Should().ContainSingle().Which.Items.Should().HaveCount(3); + held.Should().ContainSingle().Which.Arguments.GetProperty("parent_habit_id").GetString().Should().Be(habits[2].Id.ToString()); + (await bulk.ExecuteAsync(held[0].Arguments, UserId, default)).Success.Should().BeTrue(); + await _mediator.Received(3).Send(Arg.Is(command => command.ParentHabitId == habits[2].Id), Arg.Any()); + } + + [Fact] + public async Task Replay_HealthTag_DoesNotRecreateExistingHabits() + { + var habits = SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var tags = new AssignTagsTool(_habitRepo, _tagRepo, _unitOfWork); + var handler = CreateHandler(bulk, tags); + var held = HoldReplayWrites(bulk, tags); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Água"},{"title":"Academia"},{"title":"Meditação"}]}""")); + _aiIntentService.ContinueWithToolResultsAsync(Arg.Any(), Arg.Any>(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("assign_tags", """{"habit_id":"academia","tag_names":["Saúde"]}"""), Result.Success(new AiResponse { TextMessage = "Revise a tag." })); + + var result = await handler.Handle(new(UserId, "Crie uma tag Saúde e atribua à academia."), default); + + result.Value.PendingOperations.Should().ContainSingle(); + held.Should().ContainSingle().Which.OperationId.Should().Be("assign_tags"); + held[0].Arguments.GetProperty("habit_id").GetString().Should().Be(habits[2].Id.ToString()); + } + + [Fact] + public async Task Replay_PreviewReturnsNumericFactsToModel() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Novo"}]}""")); + ReplyToReplay("Revise 1 hábito."); + + var result = await handler.Handle(new(UserId, "Crie um hábito Novo."), default); + + result.Value.GroundingFacts.Facts.Should().Contain(fact => fact.Kind == "Number" && fact.Value == "1"); + Orbit.Application.Chat.Grounding.GroundednessChecker.Check(result.Value.AiMessage, result.Value.GroundingFacts).Should().BeEmpty(); + } + + [Fact] + public async Task Replay_UnresolvedNames_NoEmptyPreviewAndOneQuestion() + { + SetupResolutionAccount(); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var handler = CreateHandler(log); + var held = HoldReplayWrites(log); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_log_habits", """{"habit_ids":["meditação"]}""")); + ReplyToReplay("Escolha um hábito."); + + var result = await handler.Handle(new(UserId, "Registre meditação."), default); + + held.Should().BeEmpty(); + result.Value.PendingOperations.Should().BeEmpty(); + result.Value.AiMessage.Should().Contain("meditação").And.Contain("?"); + } + + [Fact] + public async Task Replay_PreviousCreateProposal_IsRefusedAcrossTurns() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation("Novo")]); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"NOVO","description":"A changed proposal"}]}""")); + ReplyToReplay("A prévia anterior continua disponível."); + + var result = await handler.Handle(new(UserId, "Crie Novo de novo."), default); + + result.Value.PendingOperations.Should().BeEmpty(); + held.Should().BeEmpty(); + } + + [Fact] + public async Task Replay_TagMatchingHabits_UsesOneApprovalForTheIntent() + { + var habits = SetupResolutionAccount(); + var tags = new AssignTagsTool(_habitRepo, _tagRepo, _unitOfWork); + var handler = CreateHandler(tags); + var held = HoldReplayWrites(tags); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result.Success(new AiResponse + { + ToolCalls = [new("assign_tags", "tag1", ParseArguments("""{"habit_id":"água","tag_names":["Saúde"]}""")), + new("assign_tags", "tag2", ParseArguments("""{"habit_id":"academia","tag_names":["Saúde"]}"""))], + ConversationContext = new() + })); + ReplyToReplay("Revise a tag."); + + var result = await handler.Handle(new(UserId, "Atribua Saúde a água e academia."), default); + + result.Value.PendingOperations.Should().ContainSingle().Which.Items.Should().HaveCount(2); + held.Should().ContainSingle(); + held[0].Arguments.GetProperty("habit_ids").GetArrayLength().Should().Be(2); + } + + [Fact] + public async Task Replay_LogPreview_GroundsHabitAndDateCounts() + { + var habits = SetupResolutionAccount(); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var handler = CreateHandler(log); + HoldReplayWrites(log); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_log_habits", """{"habit_ids":["água","leitura"],"dates":["2026-03-28","2026-03-29","2026-03-30","2026-03-31","2026-04-01","2026-04-03"]}""")); + ReplyToReplay("Revise 12 registros de 2 hábitos em 6 datas."); + + var result = await handler.Handle(new(UserId, "Registre água e leitura nos últimos sete dias, menos ontem."), default); + + Orbit.Application.Chat.Grounding.GroundednessChecker.Check(result.Value.AiMessage, result.Value.GroundingFacts, + habits.Select(habit => habit.Title), "pt-BR").Should().BeEmpty(); + } + + [Fact] + public async Task Replay_SixSeparateDateCalls_ProducesOneApproval() + { + SetupResolutionAccount(); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var handler = CreateHandler(log); + var held = HoldReplayWrites(log); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result.Success(new AiResponse + { + ToolCalls = Enumerable.Range(0, 7).Where(offset => offset != 1).Select(offset => + new AiToolCall("bulk_log_habits", "date" + offset, JsonSerializer.SerializeToElement(new + { + habit_ids = new[] { "água", "leitura" }, + date = Today.AddDays(-offset).ToString("yyyy-MM-dd") + }))).ToArray(), + ConversationContext = new() + })); + ReplyToReplay("Revise os registros."); + + var result = await handler.Handle(new(UserId, "Registre água e leitura nos últimos sete dias, menos ontem."), default); + + result.Value.PendingOperations.Should().ContainSingle().Which.Items.Should().HaveCount(12); + held.Should().ContainSingle(); + } + + [Fact] + public async Task Replay_BreakdownWithoutParent_AsksInsteadOfCreatingRoot() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Yoga","sub_habits":[{"title":"Aquecimento"}]}]}""")); + ReplyToReplay("Escolha o hábito."); + + var result = await handler.Handle(new(UserId, "Divida Yoga em aquecimento e prática."), default); + + held.Should().BeEmpty(); + result.Value.PendingOperations.Should().BeEmpty(); + result.Value.AiMessage.Should().Contain("Yoga").And.Contain("?"); + } + + [Fact] + public async Task Replay_DuplicateNamesInCreateBatch_AreRefused() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Meditação"},{"title":"MEDITACAO"}]}""")); + ReplyToReplay("Revise a prévia."); + + var result = await handler.Handle(new(UserId, "Crie meditação."), default); + + result.Value.PendingOperations.Should().ContainSingle().Which.Items.Should().ContainSingle(); + held.Should().ContainSingle(); + } + + private Habit[] SetupResolutionAccount() + { + var user = User.Create("Test", "test@example.com").Value; + user.SetLanguage("pt-BR"); + SetupUserAndPayGate(user); + var habits = new[] { CreateHabit("Beber água"), CreateHabit("Ler 10 minutos"), CreateHabit("Academia") }; + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any, IQueryable>?>(), Arg.Any()) + .Returns(call => habits.Where(call.Arg>>().Compile()).ToList()); + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(call => habits.Where(call.Arg>>().Compile()).ToList()); + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns(call => habits.Where(call.Arg>>().Compile()).ToList()); + return habits; + } + + private List HoldReplayWrites(params IAiTool[] tools) + { + var registry = new AiToolRegistry(tools); + var previewer = new PendingOperationChangePreviewer(_habitRepo, _goalRepo, _tagRepo, _userDateService, registry); + var held = new List(); + _operationExecutor.ExecuteAsync(Arg.Any(), Arg.Any()).Returns(async call => + { + var request = call.Arg(); + held.Add(request); + var preview = await previewer.PreviewAsync(UserId, request.OperationId, request.Arguments); + var pending = new PendingAgentOperation(Guid.NewGuid(), request.OperationId, request.OperationId, request.OperationId, + AgentRiskClass.Low, AgentConfirmationRequirement.FreshConfirmation, DateTime.UtcNow.AddMinutes(10), + Changes: preview?.Changes, ChangeTargetCount: preview?.ChangeTargetCount, Items: preview?.Items, PreviewFingerprint: preview?.PreviewFingerprint); + return new AgentExecuteOperationResponse(new AgentOperationResult(request.OperationId, request.OperationId, + AgentRiskClass.Low, AgentConfirmationRequirement.FreshConfirmation, AgentOperationStatus.PendingConfirmation), PendingOperation: pending); + }); + return held; + } + + private static Result ReplayCall(string tool, string arguments) => Result.Success(new AiResponse + { + ToolCalls = [new(tool, Guid.NewGuid().ToString(), ParseArguments(arguments))], + ConversationContext = new() + }); + + private void ReplyToReplay(string message) => + _aiIntentService.ContinueWithToolResultsAsync(Arg.Any(), Arg.Any>(), Arg.Any?>(), Arg.Any()) + .Returns(Result.Success(new AiResponse { TextMessage = message })); +} diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index da0443408..d5e5b33b1 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -3941,7 +3941,7 @@ public async Task Handle_MultipleIterations_AccumulatesActions() var handler = CreateHandler(createTool); - var toolCallArgs = JsonDocument.Parse("{}").RootElement; + var toolCallArgs = JsonDocument.Parse("""{"title":"First"}""").RootElement; var firstResponse = new AiResponse { @@ -3952,7 +3952,7 @@ public async Task Handle_MultipleIterations_AccumulatesActions() var secondResponse = new AiResponse { - ToolCalls = [new AiToolCall("create_habit", "call_2", toolCallArgs)], + ToolCalls = [new AiToolCall("create_habit", "call_2", JsonDocument.Parse("""{"title":"Second"}""").RootElement)], ConversationContext = TestConversationContext }; diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs index 90565f1fa..5cd67bea2 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs @@ -378,6 +378,140 @@ [new Claim(ClaimTypes.NameIdentifier, _userId.ToString())], "Test")) } } + [Fact] + public async Task ExecuteAsync_EmptySelection_NeverReturnsAnApproval() + { + var tools = new AiToolRegistry([new StubTool("bulk_log_habits")]); + var previewer = Substitute.For(); + previewer.PreviewAsync(_userId, "bulk_log_habits", Arg.Any(), Arg.Any()) + .Returns(new PendingOperationChangePreview([], 0, [], "empty")); + var executor = CreateExecutor(tools, previewer); + + var result = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, + "bulk_log_habits", Parse("""{"filter":{"search":"meditação"}}"""), + AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); + + result.PendingOperation.Should().BeNull(); + result.Operation.Status.Should().NotBe(AgentOperationStatus.PendingConfirmation); + } + + [Fact] + public async Task ExecuteAsync_CreateApproval_JsonStorageOrderAndUnrelatedChangeDoNotMakeItStale() + { + var today = new DateOnly(2026, 10, 19); + var unrelated = Habit.Create(new HabitCreateParams(_userId, "Beber água", FrequencyUnit.Day, 1, today)).Value; + _dbContext.Habits.Add(unrelated); + await _dbContext.SaveChangesAsync(); + var tool = new StubTool("bulk_create_habits"); + var tools = new AiToolRegistry([tool]); + var dates = Substitute.For(); + dates.GetUserTodayAsync(_userId, Arg.Any()).Returns(today); + var previewer = new PendingOperationChangePreviewer(new GenericRepository(_dbContext), + new GenericRepository(_dbContext), new GenericRepository(_dbContext), dates, tools); + var executor = CreateExecutor(tools, previewer); + var arguments = Parse("""{"habits":[{"title":"Novo","frequency_unit":"Day"}]}"""); + var held = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, tool.Name, + arguments, AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); + var stored = _dbContext.PendingAgentOperations.Single(item => item.Id == held.PendingOperation!.Id); + var reordered = """{"habits":[{"frequency_unit":"Day","title":"Novo"}]}"""; + stored.Revise(reordered, AgentOperationFingerprint.Compute(tool.Name, reordered), + held.PendingOperation!.PreviewFingerprint!, DateTime.UtcNow).Should().BeTrue(); + unrelated.Update(new HabitUpdateParams("Changed unrelated habit", unrelated.Description, unrelated.FrequencyUnit, + unrelated.FrequencyQuantity, Days: unrelated.Days.ToList(), IsBadHabit: unrelated.IsBadHabit, DueDate: unrelated.DueDate)).IsSuccess.Should().BeTrue(); + await _dbContext.SaveChangesAsync(); + _dbContext.ChangeTracker.Clear(); + var revisions = new PendingOperationRevisionService(_pendingOperationStore, previewer, + new RevisePendingOperationRequestValidator(), tools); + var execution = _pendingOperationStore.GetExecution(_userId, held.PendingOperation.Id)!; + + (await revisions.IsCurrentAsync(_userId, execution, default)).Should().BeTrue(); + var confirmation = _pendingOperationStore.Confirm(_userId, held.PendingOperation.Id)!; + var approved = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, execution.OperationId, + execution.Arguments, AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, + ConfirmationToken: confirmation.ConfirmationToken)); + + approved.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + tool.Calls.Should().Be(1); + } + + [Fact] + public async Task AssignTags_BatchApproval_ReusesOneTagAcrossExistingHabits() + { + var today = new DateOnly(2026, 10, 19); + var habits = new[] { "Beber água", "Academia" }.Select(title => + Habit.Create(new HabitCreateParams(_userId, title, FrequencyUnit.Day, 1, today)).Value).ToArray(); + _dbContext.Habits.AddRange(habits); + await _dbContext.SaveChangesAsync(); + _dbContext.ChangeTracker.Clear(); + var tool = new Orbit.Application.Chat.Tools.Implementations.AssignTagsTool( + new GenericRepository(_dbContext), new GenericRepository(_dbContext), + new UnitOfWork(_dbContext, new DatabaseConnectionSettings())); + var arguments = JsonSerializer.SerializeToElement(new { habit_ids = habits.Select(habit => habit.Id).ToArray(), tag_names = new[] { "Saúde" } }); + + (await tool.CheckArgumentsAsync(arguments, _userId, default)).IsSuccess.Should().BeTrue(); + var result = await tool.ExecuteAsync(arguments, _userId, default); + + result.Success.Should().BeTrue(); + _dbContext.ChangeTracker.Clear(); + var saved = await _dbContext.Habits.Include(habit => habit.Tags).Where(habit => habit.UserId == _userId).ToListAsync(); + saved.Should().HaveCount(2); + saved.Should().OnlyContain(habit => habit.Tags.Count == 1 && habit.Tags.Single().Name == "Saúde"); + (await _dbContext.Tags.CountAsync(tag => tag.UserId == _userId)).Should().Be(1); + } + + [Fact] + public void PendingCreateProposals_KeepParentScopeAndExcludeOtherUsersAndCancelledWrites() + { + var parentId = Guid.NewGuid(); + var capability = _catalogService.GetCapabilityByChatTool("bulk_create_habits")!; + var rootJson = """{"habits":[{"title":"Novo"}]}"""; + var childJson = JsonSerializer.Serialize(new { parent_habit_id = parentId, habits = new[] { new { title = "Treino" } } }); + var root = _pendingOperationStore.Create(_userId, capability, "bulk_create_habits", rootJson, "Create", + AgentOperationFingerprint.Compute("bulk_create_habits", rootJson), AgentExecutionSurface.Chat); + _pendingOperationStore.Create(_userId, capability, "bulk_create_habits", childJson, "Create children", + AgentOperationFingerprint.Compute("bulk_create_habits", childJson), AgentExecutionSurface.Chat); + _pendingOperationStore.Create(Guid.NewGuid(), capability, "bulk_create_habits", rootJson, "Other user", + AgentOperationFingerprint.Compute("bulk_create_habits", rootJson), AgentExecutionSurface.Chat); + + _pendingOperationStore.GetPendingHabitCreations(_userId).Should().BeEquivalentTo( + [new PendingHabitCreation("Novo"), new PendingHabitCreation("Treino", parentId)]); + _pendingOperationStore.Cancel(_userId, root.Id, + AgentOperationFingerprint.Compute("bulk_create_habits", rootJson)).Should().BeTrue(); + _pendingOperationStore.GetPendingHabitCreations(_userId).Should().Equal(new PendingHabitCreation("Treino", parentId)); + } + + [Fact] + public async Task ReviseDateRange_KeepingOneDate_PreservesTheOfferedItem() + { + var habit = Habit.Create(new HabitCreateParams(_userId, "Beber água", FrequencyUnit.Day, 1, + new DateOnly(2026, 10, 19))).Value; + _dbContext.Habits.Add(habit); + await _dbContext.SaveChangesAsync(); + var tools = new AiToolRegistry([new StubTool("bulk_log_habits")]); + var previewer = new PendingOperationChangePreviewer(new GenericRepository(_dbContext), + new GenericRepository(_dbContext), new GenericRepository(_dbContext), Substitute.For(), tools); + var executor = CreateExecutor(tools, previewer); + var arguments = JsonSerializer.SerializeToElement(new { revised_items = new[] + { + new { habit_id = habit.Id, date = "2026-10-18" }, new { habit_id = habit.Id, date = "2026-10-19" } + } }); + var held = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, "bulk_log_habits", + arguments, AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); + var pending = held.PendingOperation!; + var chosen = pending.Items![0]; + var revisions = new PendingOperationRevisionService(_pendingOperationStore, previewer, + new RevisePendingOperationRequestValidator(), tools); + + var result = await revisions.ReviseAsync(_userId, pending.Id, + new RevisePendingOperationRequest(pending.PreviewFingerprint!, [new(chosen.ItemId)]), default); + + result.IsSuccess.Should().BeTrue(result.Error); + result.Preview!.Items.Should().ContainSingle().Which.ItemId.Should().Be(chosen.ItemId); + var execution = _pendingOperationStore.GetExecution(_userId, pending.Id)!; + execution.Arguments.GetProperty("revised_items").GetArrayLength().Should().Be(1); + (await revisions.IsCurrentAsync(_userId, execution, default)).Should().BeTrue(); + } + private AgentOperationExecutor CreateExecutor(AiToolRegistry toolRegistry, IPendingOperationChangePreviewer? previewer = null) { From aa5e4ccb621baef79fecd338dde666bc49dbd754 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 17:59:29 -0300 Subject: [PATCH 4/9] fix: preserve date preview identities when narrowing a log range --- .../Chat/PendingOperationChangePreviewer.cs | 9 ++++++++- .../Chat/PendingOperationRevisionService.cs | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs index b228986a0..55e8b8c7a 100644 --- a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs +++ b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs @@ -195,6 +195,11 @@ private static bool IsDatedRevision(string operationId, bool revised) => var byId = habits.ToDictionary(habit => habit.Id); if (datedItems is null || datedItems.Any(item => !byId.ContainsKey(item.HabitId))) return null; + var stableIds = arguments.GetProperty("revised_items").EnumerateArray() + .Where(item => item.TryGetProperty("preview_item_id", out var id) && id.ValueKind == JsonValueKind.String) + .ToDictionary(item => (Guid.Parse(item.GetProperty("habit_id").GetString()!), + DateOnly.ParseExact(item.GetProperty("date").GetString()!, "yyyy-MM-dd", CultureInfo.InvariantCulture)), + item => item.GetProperty("preview_item_id").GetString()!); var multipleDates = datedItems.GroupBy(item => item.HabitId).Any(group => group.Count() > 1); var datedPreview = datedItems.OrderBy(item => item.HabitId).ThenBy(item => item.Date).Select(item => { @@ -202,7 +207,9 @@ private static bool IsDatedRevision(string operationId, bool revised) => var date = item.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var change = new PendingOperationChange(habit.Id, habit.Title, "date", null, date, "date", JsonSerializer.SerializeToElement(date), true); - return new PendingOperationItem(multipleDates ? $"{habit.Id}:{date}" : habit.Id.ToString(), + var itemId = stableIds.TryGetValue((item.HabitId, item.Date), out var stableId) + ? stableId : multipleDates ? $"{habit.Id}:{date}" : habit.Id.ToString(); + return new PendingOperationItem(itemId, habit.Id, habit.Title, [change], FingerprintHabit(habit), RemovesData: false); }).ToList(); return BuildPreview(operationId, datedPreview.Take(10).SelectMany(item => item.Fields).ToList(), datedPreview); diff --git a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs index 3a03e742e..138f43417 100644 --- a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs +++ b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs @@ -338,7 +338,13 @@ private static bool BuildDatedHabitArguments(JsonObject root, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _)) return false; - items.Add(new JsonObject { ["habit_id"] = offered[item.ItemId].EntityId?.ToString(), ["date"] = date }); + var targetId = offered[item.ItemId].EntityId?.ToString(); + if (targetId is null) + return false; + var revisedItem = new JsonObject { ["habit_id"] = targetId, ["date"] = date }; + if (item.ItemId != targetId) + revisedItem["preview_item_id"] = item.ItemId; + items.Add(revisedItem); } root.Remove("filter"); root.Remove("habit_ids"); From e304bfbb88ffea2d6675506bdb4212df117a5924 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 18:02:12 -0300 Subject: [PATCH 5/9] fix: stop Astra approval when its preview cannot be prepared --- .../Chat/PendingOperationPreview.cs | 13 +++++++++++-- .../Services/AgentOperationExecutor.cs | 10 +++++++++- .../Services/AgentExecutionAndSanitizerTests.cs | 7 ++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/Orbit.Application/Chat/PendingOperationPreview.cs b/src/Orbit.Application/Chat/PendingOperationPreview.cs index 2376d8147..08e1807f3 100644 --- a/src/Orbit.Application/Chat/PendingOperationPreview.cs +++ b/src/Orbit.Application/Chat/PendingOperationPreview.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Orbit.Domain.Common; using Microsoft.Extensions.Logging; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; @@ -10,15 +11,23 @@ public static class PendingOperationPreview public static async Task PreviewSafelyAsync( this IPendingOperationChangePreviewer previewer, Guid userId, string operationId, JsonElement arguments, ILogger logger, CancellationToken cancellationToken) + { + var result = await previewer.TryPreviewAsync(userId, operationId, arguments, logger, cancellationToken); + return result.IsSuccess ? result.Value : null; + } + + public static async Task> TryPreviewAsync( + this IPendingOperationChangePreviewer previewer, Guid userId, string operationId, + JsonElement arguments, ILogger logger, CancellationToken cancellationToken) { try { - return await previewer.PreviewAsync(userId, operationId, arguments, cancellationToken); + return Result.Success(await previewer.PreviewAsync(userId, operationId, arguments, cancellationToken)); } catch (Exception exception) { logger.LogWarning(exception, "Pending operation change preview failed for {OperationId}", operationId); - return null; + return Result.Failure("preview_unavailable"); } } } diff --git a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs index 28e0529e6..02b79b352 100644 --- a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs +++ b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs @@ -211,8 +211,16 @@ await TryAuditAsync( if (pending is not null && execution.Request.Surface == AgentExecutionSurface.Chat && execution.Request.IncludeChangePreview) { - var preview = await changePreviewer.PreviewSafelyAsync( + var attemptedPreview = await changePreviewer.TryPreviewAsync( execution.Request.UserId, execution.Operation.Id, execution.Arguments, logger, cancellationToken); + if (attemptedPreview.IsFailure) + { + pendingOperationStore.Cancel(execution.Request.UserId, pending.Id, + AgentOperationFingerprint.Compute(execution.Operation.Id, execution.Arguments.GetRawText())); + return await BuildToolOutcomeResponseAsync(execution, policyDecision, + new ToolResult(false, Error: attemptedPreview.Error), cancellationToken); + } + var preview = attemptedPreview.Value; if (preview is { ChangeTargetCount: 0 } || preview?.Items is { Count: 0 } || preview is null && RequiresMatchedHabits(execution.Operation.Id)) diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs index 699f79d33..406c16ee7 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs @@ -357,7 +357,7 @@ public async Task AgentOperationExecutor_PreviewsOnlyCapableChatConfirmations( } [Fact] - public async Task AgentOperationExecutor_PreviewFailure_KeepsPendingConfirmation() + public async Task AgentOperationExecutor_PreviewFailure_DoesNotOfferUnboundApproval() { var catalog = Substitute.For(); var capability = CreateCapability(AgentCapabilityIds.HabitsBulkWrite, AgentScopes.WriteHabits, @@ -381,8 +381,9 @@ public async Task AgentOperationExecutor_PreviewFailure_KeepsPendingConfirmation UserId, operation.Id, Parse("{}"), AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); - response.Operation.Status.Should().Be(AgentOperationStatus.PendingConfirmation); - response.PendingOperation!.Changes.Should().BeNull(); + response.Operation.Status.Should().Be(AgentOperationStatus.Failed); + response.Operation.PolicyReason.Should().Be("preview_unavailable"); + response.PendingOperation.Should().BeNull(); } [Fact] From 00d0fe30ae760a44c8ecd4c22f1ab1d83816c53c Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 18:04:58 -0300 Subject: [PATCH 6/9] fix: match pending Astra creates by partial habit names --- .../Chat/Commands/ProcessUserChatCommand.Resolution.cs | 4 +++- .../ProcessUserChatCommandHandlerTests.Resolution.cs | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs index 8ab2b5e1b..fcd27618b 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs @@ -141,8 +141,10 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati results.MissingHabitNames.Add(name); continue; } - if (!results.ProposedHabitNames.Add((parentId, HabitNameMatcher.Normalize(name)))) + if (results.ProposedHabitNames.Any(previous => previous.ParentId == parentId + && (HabitNameMatcher.Matches(previous.Name, name) || HabitNameMatcher.Matches(name, previous.Name)))) continue; + results.ProposedHabitNames.Add((parentId, HabitNameMatcher.Normalize(name))); proposed.Add(item.DeepClone()); } return proposed; diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs index d2a6b78fb..2d62fcef4 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs @@ -136,16 +136,18 @@ public async Task Replay_UnresolvedNames_NoEmptyPreviewAndOneQuestion() result.Value.AiMessage.Should().Contain("meditação").And.Contain("?"); } - [Fact] - public async Task Replay_PreviousCreateProposal_IsRefusedAcrossTurns() + [Theory] + [InlineData("Novo", "NOVO")] + [InlineData("Praticar yoga", "Yoga")] + public async Task Replay_PreviousCreateProposal_IsRefusedAcrossTurns(string previousName, string proposedName) { SetupResolutionAccount(); var bulk = new BulkCreateHabitsTool(_mediator); var handler = CreateHandler(bulk); var held = HoldReplayWrites(bulk); - _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation("Novo")]); + _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation(previousName)]); _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) - .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"NOVO","description":"A changed proposal"}]}""")); + .Returns(ReplayCall("bulk_create_habits", JsonSerializer.Serialize(new { habits = new[] { new { title = proposedName, description = "A changed proposal" } } }))); ReplyToReplay("A prévia anterior continua disponível."); var result = await handler.Handle(new(UserId, "Crie Novo de novo."), default); From df566c9251e11471f518ffe7b6aab2cd72a9eede Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 18:24:49 -0300 Subject: [PATCH 7/9] fix: stop dated Astra revisions from re-expanding Preserve selected habit/date items and their offered identities across revision, refresh, and execution. Make expanded previews terminal and remove the original date range from revised approvals. Refs thomasluizon/orbit-tickets#1568 --- .../Chat/PendingOperationChangePreviewer.cs | 23 ++--- .../Chat/PendingOperationRevisionService.cs | 1 + .../DatedPendingOperationRevisionTests.cs | 99 +++++++++++++++++++ .../PendingOperationChangePreviewerTests.cs | 39 ++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 tests/Orbit.Application.Tests/Chat/DatedPendingOperationRevisionTests.cs diff --git a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs index 55e8b8c7a..3aba19849 100644 --- a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs +++ b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs @@ -37,7 +37,13 @@ public sealed class PendingOperationChangePreviewer( CancellationToken cancellationToken = default) { if (HasLogDates(operationId, arguments)) - return await PreviewLogDatesAsync(userId, operationId, arguments, cancellationToken); + { + var expanded = await BulkHabitLogArguments.ExpandAsync(arguments, habitRepository, + userDateService, userId, cancellationToken); + if (expanded.IsFailure) + return null; + arguments = expanded.Value; + } if (operationId == "bulk_create_habits") return await PreviewCreateAsync(userId, arguments, cancellationToken); @@ -56,8 +62,9 @@ public sealed class PendingOperationChangePreviewer( if (operationId == "delete_habit") return await PreviewSingleHabitAsync(userId, arguments, cancellationToken); - var isRevised = arguments.TryGetProperty("revised_items", out var revisedItems) - && revisedItems.ValueKind == JsonValueKind.Array; + var isRevised = arguments.TryGetProperty("revised_items", out var revisedItems); + if (isRevised && revisedItems.ValueKind != JsonValueKind.Array) + return null; var revisedChanges = new Dictionary(); var (filter, filterError) = ResolveFilter(operationId, arguments, isRevised, revisedItems, revisedChanges); @@ -176,14 +183,8 @@ private static HashSet DependencyIds(JsonElement arguments, params string[ } private static bool HasLogDates(string operationId, JsonElement arguments) => - operationId == "bulk_log_habits" && arguments.TryGetProperty("dates", out _); - - private async Task PreviewLogDatesAsync(Guid userId, string operationId, - JsonElement arguments, CancellationToken ct) - { - var expanded = await BulkHabitLogArguments.ExpandAsync(arguments, habitRepository, userDateService, userId, ct); - return expanded.IsSuccess ? await PreviewAsync(userId, operationId, expanded.Value, ct) : null; - } + operationId == "bulk_log_habits" && arguments.TryGetProperty("dates", out _) + && !arguments.TryGetProperty("revised_items", out _); private static bool IsDatedRevision(string operationId, bool revised) => revised && operationId is "bulk_log_habits" or "bulk_skip_habits"; diff --git a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs index 138f43417..e0adae7e4 100644 --- a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs +++ b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs @@ -348,6 +348,7 @@ private static bool BuildDatedHabitArguments(JsonObject root, } root.Remove("filter"); root.Remove("habit_ids"); + root.Remove("dates"); root["revised_items"] = items; return true; } diff --git a/tests/Orbit.Application.Tests/Chat/DatedPendingOperationRevisionTests.cs b/tests/Orbit.Application.Tests/Chat/DatedPendingOperationRevisionTests.cs new file mode 100644 index 000000000..11f7bbb95 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/DatedPendingOperationRevisionTests.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Chat; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Validators; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Application.Tests.Chat; + +public sealed class DatedPendingOperationRevisionTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReviseDateRange_KeepingOneDate_PreservesTheOfferedItem(bool editDate) + { + using var context = new HeldWriteTestContext(); + context.Dates.GetUserTodayAsync(context.UserId, Arg.Any()) + .Returns(new DateOnly(2026, 10, 19)); + var other = Habit.Create(new HabitCreateParams(context.UserId, "Water", FrequencyUnit.Day, 1, + new DateOnly(2026, 9, 25))).Value; + context.Records.Add(other); + var tool = context.Tool("bulk_log_habits"); + var registry = new AiToolRegistry([tool]); + var previewer = new PendingOperationChangePreviewer(context.Repository(), + context.Repository(), context.Repository(), context.Dates, registry); + var arguments = JsonSerializer.SerializeToElement(new + { + habit_ids = new[] { context.Habit.Id, other.Id }, + dates = new[] { "2026-10-18", "2026-10-19" } + }); + var original = await previewer.PreviewAsync(context.UserId, tool.Name, arguments); + original!.Items.Should().HaveCount(4); + var selected = original.Items!.Single(item => item.EntityId == context.Habit.Id + && item.Fields.Single().NewValue == "2026-10-19"); + var pendingId = Guid.NewGuid(); + var current = new PendingAgentOperationExecution(pendingId, AgentCapabilityIds.HabitsBulkWrite, + tool.Name, arguments, AgentExecutionSurface.Chat, AgentConfirmationRequirement.FreshConfirmation, + PreviewFingerprint: original.PreviewFingerprint); + var store = Substitute.For(); + store.GetExecution(context.UserId, pendingId).Returns(_ => current); + store.Revise(context.UserId, pendingId, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(call => + { + current = current with + { + Arguments = JsonSerializer.Deserialize(call.ArgAt(3)), + PreviewFingerprint = call.ArgAt(5) + }; + return true; + }); + var service = new PendingOperationRevisionService(store, previewer, + new RevisePendingOperationRequestValidator(), registry); + var date = editDate ? "2026-10-17" : "2026-10-19"; + + var result = await service.ReviseAsync(context.UserId, pendingId, + new RevisePendingOperationRequest(original.PreviewFingerprint!, + [new(selected.ItemId, editDate ? JsonSerializer.SerializeToElement(new { date }) : null)]), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(result.Error); + var retained = result.Preview!.Items.Should().ContainSingle().Subject; + retained.ItemId.Should().Be(selected.ItemId); + retained.EntityId.Should().Be(context.Habit.Id); + retained.Fields.Should().ContainSingle().Which.NewValue.Should().Be(date); + current.Arguments.TryGetProperty("dates", out _).Should().BeFalse(); + current.Arguments.TryGetProperty("habit_ids", out _).Should().BeFalse(); + var stored = current.Arguments.GetProperty("revised_items").EnumerateArray().Should().ContainSingle().Subject; + stored.GetProperty("habit_id").GetGuid().Should().Be(context.Habit.Id); + stored.GetProperty("date").GetString().Should().Be(date); + stored.GetProperty("preview_item_id").GetString().Should().Be(selected.ItemId); + context.Commands.Should().BeEmpty(); + + var refreshed = await service.RefreshAsync(context.UserId, pendingId, CancellationToken.None); + + refreshed.IsSuccess.Should().BeTrue(refreshed.Error); + refreshed.Preview!.Items.Should().ContainSingle().Which.ItemId.Should().Be(selected.ItemId); + (await service.IsCurrentAsync(context.UserId, current, CancellationToken.None)).Should().BeTrue(); + + var editedAgain = await service.ReviseAsync(context.UserId, pendingId, + new RevisePendingOperationRequest(refreshed.Preview.PreviewFingerprint!, + [new(selected.ItemId, JsonSerializer.SerializeToElement(new { date = "2026-10-16" }))]), + CancellationToken.None); + + editedAgain.IsSuccess.Should().BeTrue(editedAgain.Error); + editedAgain.Preview!.Items.Should().ContainSingle().Which.ItemId.Should().Be(selected.ItemId); + context.Commands.Should().BeEmpty(); + var execution = await tool.ExecuteAsync(current.Arguments, context.UserId, CancellationToken.None); + + execution.Success.Should().BeTrue(execution.Error); + var command = context.Commands.Should().ContainSingle().Subject.Should().BeOfType().Subject; + command.Items.Should().ContainSingle().Which.Should().Be(new BulkLogItem(context.Habit.Id, new DateOnly(2026, 10, 16))); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs index d79e36214..45ec8e638 100644 --- a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs +++ b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs @@ -46,6 +46,45 @@ public async Task PreviewAsync_NoMatches_DoesNotOfferEmptyPreview() preview.Should().BeNull(); } + [Theory] + [InlineData("bulk_log_habits")] + [InlineData("bulk_skip_habits")] + public async Task PreviewAsync_RevisedDates_IgnoreOriginalRangeAndKeepOfferedIdentity(string operationId) + { + var habit = CreateHabit("Read", null); + Setup([habit]); + var itemId = $"{habit.Id}:2026-10-19"; + var arguments = JsonSerializer.Serialize(new + { + habit_ids = new[] { habit.Id }, + dates = new[] { "2026-10-18", "2026-10-19" }, + revised_items = new[] { new { habit_id = habit.Id, date = "2026-10-17", preview_item_id = itemId } } + }); + + var preview = await Preview(operationId, arguments); + + var retained = preview!.Items.Should().ContainSingle().Subject; + retained.ItemId.Should().Be(itemId); + retained.EntityId.Should().Be(habit.Id); + retained.Fields.Should().ContainSingle().Which.NewValue.Should().Be("2026-10-17"); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + public async Task PreviewAsync_InvalidRevisedDates_DoNotFallBackToOriginalRange(string revisedItems) + { + var habit = CreateHabit("Read", null); + Setup([habit]); + + var preview = await Preview("bulk_log_habits", $$""" + {"habit_ids":["{{habit.Id}}"],"dates":["2026-10-18","2026-10-19"],"revised_items":{{revisedItems}}} + """); + + preview.Should().BeNull(); + } + [Fact] public async Task PreviewAsync_GoalLink_OnlyLinkedRecordChangesInvalidateApproval() { From d529eb17281d9d01d8f6a04550cec156ff7e02e3 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 19:42:15 -0300 Subject: [PATCH 8/9] fix: make Astra previews explicit and dependency checked --- .../ProcessUserChatCommand.Accumulator.cs | 6 + .../ProcessUserChatCommand.Context.cs | 4 +- .../ProcessUserChatCommand.Resolution.cs | 221 ++++++++++++----- .../Commands/ProcessUserChatCommand.Tools.cs | 11 +- .../Chat/Commands/ProcessUserChatCommand.cs | 5 +- .../Chat/HabitNameMatcher.cs | 7 +- .../Chat/PendingOperationChangePreviewer.cs | 67 +++-- .../Chat/PendingOperationRevisionService.cs | 4 +- .../Tools/Implementations/AssignTagsTool.cs | 56 +++-- .../Implementations/BulkHabitLogArguments.cs | 10 +- .../Implementations/BulkLogHabitsTool.cs | 3 +- .../Tools/Implementations/QueryHabitsTool.cs | 42 ++-- .../Habits/Commands/BulkHabitSelection.cs | 9 +- .../Interfaces/IAgentPlatformServices.cs | 3 + .../Services/AgentOperationExecutor.cs | 63 ++--- .../Services/PendingAgentOperationStore.cs | 47 +++- .../Sections/Static/GlobalRulesSection.cs | 6 +- .../Chat/AstraApprovalPathTests.cs | 232 ++++++++++++++++++ .../PendingOperationChangePreviewerTests.cs | 39 +++ .../Chat/Tools/AssignTagsToolTests.cs | 21 ++ .../Chat/ChatWriteHoldHandlerTests.cs | 4 +- ...sUserChatCommandHandlerTests.Resolution.cs | 214 +++++++++++++++- .../Controllers/AiControllerTests.cs | 13 +- .../Services/AgentChatWriteHoldTests.cs | 56 ++++- .../AgentExecutionAndSanitizerTests.cs | 24 +- .../AstraHabitQueryRegressionTests.cs | 43 +++- .../Services/BulkCreateAgentOperationTests.cs | 8 +- 27 files changed, 1032 insertions(+), 186 deletions(-) create mode 100644 tests/Orbit.Application.Tests/Chat/AstraApprovalPathTests.cs diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs index abc476c54..8aea1cab2 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs @@ -17,9 +17,15 @@ internal sealed class ToolExecutionAccumulator private readonly SemaphoreSlim _toolStepGate = new(1, 1); public IReadOnlyList Habits { get; init; } = []; + public IReadOnlyList AllHabits { get; init; } = []; public bool RequiresExistingParent { get; init; } public HashSet<(Guid? ParentId, string Name)> ProposedHabitNames { get; } = []; + public Dictionary<(Guid? ParentId, string Name), string> PendingHabitNames { get; } = []; public bool IsTaggingIntent { get; init; } + public HashSet ExistingDuplicateNames { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet PendingDuplicateNames { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary AmbiguousHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary InvalidCalls { get; } = new(StringComparer.Ordinal); public HashSet MissingHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet PreviewedCalls { get; } = new(StringComparer.Ordinal); diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs index f2193881a..e8fa3a458 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs @@ -20,7 +20,7 @@ private async Task> LoadChatContextAsync( var userHabits = await data.HabitRepository.FindAsync( h => h.UserId == request.UserId, - q => q, + q => q.Include(habit => habit.Tags), cancellationToken); var activeHabits = userHabits.Where(habit => !habit.IsCompleted).ToList(); var userToday = await execution.UserDateService.GetUserTodayAsync(request.UserId, cancellationToken); @@ -69,6 +69,7 @@ private async Task> LoadChatContextAsync( LogContextLoaded(logger, dbStopwatch.ElapsedMilliseconds, activeHabits.Count); return Result.Success(new ChatContext( + userHabits, activeHabits, todayHabits, promptHabitIndex.Habits, @@ -189,6 +190,7 @@ internal sealed record PromptHabitIndex( IReadOnlySet DoneTodayHabitIds); private sealed record ChatContext( + IReadOnlyList UserHabits, List ActiveHabits, List TodayHabits, List PromptHabits, diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs index fcd27618b..7c9c6334e 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs @@ -2,26 +2,77 @@ using System.Text.RegularExpressions; using System.Text.Json.Nodes; using Orbit.Domain.Models; +using Orbit.Domain.Entities; +using Orbit.Application.Habits.Commands; using Orbit.Application.Chat.Tools.Implementations; namespace Orbit.Application.Chat.Commands; public partial class ProcessUserChatCommandHandler { + private const string PortuguesePendingQuestion = "Você já tem uma prévia para criar"; + private const string EnglishPendingQuestion = "You already have a preview to create"; + + private static bool IsExplicitHabitCreate(string message) => Regex.IsMatch(message, + @"\b(crie|cria|criar|create|add|adicione)\s+(?:(?:um|uma|o|a|os|as|new|one|novo|nova)\s+)?(?:h[aá]bitos?|habits?|rotina)\b", + RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + private static bool IsTagOnlyIntent(string message) => Regex.IsMatch(message, @"\b(tags?|etiquetas?)\b", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)) - && !Regex.IsMatch(message, @"\b(crie|criar|create|add|adicione)\s+(?:(?:um|uma|new|a|one|novo|nova)\s+)?(?:h[aá]bitos?|habits?|rotina)\b", - RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + && !IsExplicitHabitCreate(message); - private void SeedProposedHabitNames(Guid userId, ToolExecutionAccumulator results) + private static bool RequestsExistingParent(string message, IReadOnlyList habits) { - foreach (var name in execution.PendingOperationStore.GetPendingHabitCreations(userId)) - results.ProposedHabitNames.Add((name.ParentHabitId, HabitNameMatcher.Normalize(name.Title))); + var normalized = HabitNameMatcher.Normalize(message); + if (Regex.IsMatch(normalized, @"\b(divida|dividir|split|break down)\b", RegexOptions.None, TimeSpan.FromSeconds(1))) + return true; + var asksForChildren = Regex.IsMatch(normalized, @"\b(sub habitos?|subhabitos?|children|child)\b", + RegexOptions.None, TimeSpan.FromSeconds(1)); + return habits.Any(habit => Regex.IsMatch(normalized, + @"\b(under|dentro d[oa]|sob [oa]|n[oa] habito|ao habito)\s+" + Regex.Escape(HabitNameMatcher.Normalize(habit.Title)) + @"\b", + RegexOptions.None, TimeSpan.FromSeconds(1)) + || asksForChildren && Regex.IsMatch(normalized, + @"\b(no|na|ao|do|da|of|em)\s+(?:habito\s+)?" + Regex.Escape(HabitNameMatcher.Normalize(habit.Title)) + @"\b", + RegexOptions.None, TimeSpan.FromSeconds(1))); } + private void SeedProposedHabitNames(ProcessUserChatCommand request, ToolExecutionAccumulator results) + { + var previousReply = request.History?.LastOrDefault(item => + ChatHistoryMessage.NormalizeRole(item.Role) == ChatHistoryMessage.AssistantRole)?.Content; + var proposals = execution.PendingOperationStore.GetPendingHabitCreations(request.UserId); + foreach (var name in proposals) + { + var discussed = previousReply is not null + && (previousReply.Contains(PortuguesePendingQuestion, StringComparison.Ordinal) + || previousReply.Contains(EnglishPendingQuestion, StringComparison.Ordinal)) + && HabitNameMatcher.Matches(previousReply, name.Title); + var named = HabitNameMatcher.Matches(request.Message, name.Title); + var asksForReplacement = Regex.IsMatch(HabitNameMatcher.Normalize(request.Message), + @"\b(nova|new|outra|another)\s+(previa|preview)\b|\b(prepare|preparar|make)\s+(?:(?:um|uma|a)\s+)?(?:nova|new|outra|another)(?: one)?$", + RegexOptions.None, TimeSpan.FromSeconds(1)); + var explicitlyReplaces = asksForReplacement && (named || proposals.Count == 1) + || discussed && named && Regex.IsMatch(request.Message, + @"\b(crie|cria|criar|create|prepare|preparar|nova|new|again|de novo)\b", + RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + if (explicitlyReplaces) + { + execution.PendingOperationStore.SupersedeHabitCreation(request.UserId, name.Title, name.ParentHabitId); + continue; + } + var key = (name.ParentHabitId, HabitNameMatcher.Normalize(name.Title)); + results.ProposedHabitNames.Add(key); + results.PendingHabitNames[key] = name.Title; + } + } + + private static bool HasResolutionQuestion(ToolExecutionAccumulator results) => + results.MissingHabitNames.Count + results.ExistingDuplicateNames.Count + + results.PendingDuplicateNames.Count + results.AmbiguousHabitNames.Count > 0; + private static ResponseCards ApplyResolutionToCards(ResponseCards cards, ToolExecutionAccumulator results, string? language) { - if (results.MissingHabitNames.Count == 0) + if (!HasResolutionQuestion(results)) return cards; var message = AddResolutionQuestion(cards.AiMessage, results, language)!; return cards with @@ -34,19 +85,25 @@ private static ResponseCards ApplyResolutionToCards(ResponseCards cards, ToolExe private static AiToolCall? ResolveHabitCall(AiToolCall call, ToolExecutionAccumulator results) { - if (JsonNode.Parse(call.Args.GetRawText()) is not JsonObject arguments) - return call; - - if (!ResolveHabitIdentifiers(arguments, call.Name, results)) + if (JsonNode.Parse(call.Args.GetRawText()) is not JsonObject arguments + || new[] { "habit_id", "parent_habit_id" }.Any(key => arguments[key] is not null + && (arguments[key] is not JsonValue value || !value.TryGetValue(out _))) + || arguments["habit_ids"] is { } ids && (ids is not JsonArray array + || array.Any(item => item is not JsonValue value || !value.TryGetValue(out _)))) + { + results.InvalidCalls[call.Id] = "Tool arguments must be an object with string habit identifiers."; return null; + } - if (arguments["filter"] is JsonObject filter && filter["search"] is JsonValue searchValue - && searchValue.TryGetValue(out var search) - && !results.Habits.Any(habit => HabitNameMatcher.Matches(habit.Title, search))) + if (arguments["parent_habit_id"] is not null && !results.RequiresExistingParent + && call.Name is "create_habit" or "bulk_create_habits" or "create_sub_habit") { - results.MissingHabitNames.Add(search); + results.InvalidCalls[call.Id] = "Only assign an existing parent when the person asks for children under it."; return null; } + if (!ResolveHabitIdentifiers(arguments, call.Name, results) + || !ResolveSearchFilter(call.Name, arguments, results)) + return null; if (call.Name is "create_habit" or "bulk_create_habits" or "create_sub_habit") return ResolveHabitCreation(call, arguments, results); @@ -54,6 +111,19 @@ private static ResponseCards ApplyResolutionToCards(ResponseCards cards, ToolExe return call with { Args = JsonSerializer.SerializeToElement(arguments) }; } + private static bool ResolveSearchFilter(string operationId, JsonObject arguments, ToolExecutionAccumulator results) + { + if (!operationId.StartsWith("bulk_", StringComparison.Ordinal) || operationId == "bulk_create_habits" + || arguments["filter"] is not JsonObject filter || filter["search"] is not JsonValue searchValue + || !searchValue.TryGetValue(out var search) || string.IsNullOrWhiteSpace(search)) + return true; + var parsed = BulkHabitToolArguments.ParseRequiredFilter(JsonSerializer.SerializeToElement(arguments)); + if (parsed.Error is not null || parsed.Filter is null || BulkHabitSelection.Select(results.AllHabits, parsed.Filter).Count > 0) + return true; + results.MissingHabitNames.Add(search); + return false; + } + private static bool ResolveHabitIdentifiers(JsonObject arguments, string operationId, ToolExecutionAccumulator results) { foreach (var key in new[] { "habit_id", "parent_habit_id" }) @@ -61,12 +131,9 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati if (arguments[key] is not JsonValue value || !value.TryGetValue(out var name) || Guid.TryParse(name, out _)) continue; - var habit = HabitNameMatcher.Resolve(results.Habits, name); + var habit = ResolveHabitName(results, name); if (habit is null) - { - results.MissingHabitNames.Add(name); return false; - } arguments[key] = habit.Id.ToString(); } @@ -79,10 +146,8 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati return true; if (Guid.TryParse(name, out var id)) ids.Add(id); - else if (HabitNameMatcher.Resolve(results.Habits, name) is { } habit) + else if (ResolveHabitName(results, name) is { } habit) ids.Add(habit.Id); - else - results.MissingHabitNames.Add(name); } if (ids.Count == 0) return false; @@ -97,12 +162,15 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati private static AiToolCall? ResolveHabitCreation(AiToolCall call, JsonObject arguments, ToolExecutionAccumulator results) { var items = call.Name == "bulk_create_habits" ? arguments["habits"] as JsonArray : new JsonArray(arguments.DeepClone()); - if (items is null) - return call; - if (items.Count == 1 && items[0] is JsonObject root + if (items is null || items.Any(item => item is not JsonObject)) + { + results.InvalidCalls[call.Id] = "Habits must be an array of objects."; + return null; + } + if (results.RequiresExistingParent && items.Count == 1 && items[0] is JsonObject root && root["title"] is JsonValue titleValue && titleValue.TryGetValue(out var title) && root["sub_habits"] is JsonArray children - && HabitNameMatcher.Resolve(results.Habits, title) is { } parent) + && ResolveHabitName(results, title) is { } parent) { arguments = new JsonObject { ["parent_habit_id"] = parent.Id.ToString(), ["habits"] = children.DeepClone() }; call = call with { Name = "bulk_create_habits" }; @@ -110,7 +178,9 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati } else if (arguments["parent_habit_id"] is null && results.RequiresExistingParent) { - results.MissingHabitNames.Add(items.FirstOrDefault()?["title"]?.ToString() ?? string.Empty); + var name = (items.FirstOrDefault() as JsonObject)?["title"]?.ToString() ?? string.Empty; + if (!results.AmbiguousHabitNames.ContainsKey(name)) + results.MissingHabitNames.Add(name); return null; } var proposed = NewHabitItems(items, arguments, results); @@ -133,17 +203,21 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati { if (item["title"] is not JsonValue value || !value.TryGetValue(out var name) || string.IsNullOrWhiteSpace(name)) return null; - if (results.Habits.Any(habit => (parentId is null || habit.ParentHabitId == parentId) - && HabitNameMatcher.Matches(habit.Title, name))) - continue; if (results.IsTaggingIntent) + continue; + var existing = results.Habits.FirstOrDefault(habit => habit.ParentHabitId == parentId + && HabitNameMatcher.Normalize(habit.Title) == HabitNameMatcher.Normalize(name)); + if (existing is not null) { - results.MissingHabitNames.Add(name); + results.ExistingDuplicateNames.Add(existing.Title); continue; } - if (results.ProposedHabitNames.Any(previous => previous.ParentId == parentId - && (HabitNameMatcher.Matches(previous.Name, name) || HabitNameMatcher.Matches(name, previous.Name)))) + if (results.ProposedHabitNames.Contains((parentId, HabitNameMatcher.Normalize(name)))) + { + results.PendingDuplicateNames.Add(results.PendingHabitNames.GetValueOrDefault( + (parentId, HabitNameMatcher.Normalize(name)), name)); continue; + } results.ProposedHabitNames.Add((parentId, HabitNameMatcher.Normalize(name))); proposed.Add(item.DeepClone()); } @@ -151,33 +225,40 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati } private async Task CoalesceLogCallsAsync(IReadOnlyList calls, - Dictionary resolvedCalls, Guid userId, CancellationToken ct) + Dictionary resolvedCalls, Guid userId, ToolExecutionAccumulator results, CancellationToken ct) { var logs = calls.Select(call => resolvedCalls[call.Id]).OfType() .Where(call => call.Name == "bulk_log_habits").ToList(); - if (logs.Count < 2) + if (logs.Count == 0) return; var entries = new Dictionary(StringComparer.Ordinal); foreach (var call in logs) { var expanded = await BulkHabitLogArguments.ExpandAsync(call.Args, data.HabitRepository, - execution.UserDateService, userId, ct); + execution.UserDateService, userId, ct, results.AllHabits); if (expanded.IsFailure) - return; + { + results.InvalidCalls[call.Id] = expanded.Error!; + resolvedCalls[call.Id] = null; + continue; + } foreach (var item in expanded.Value.GetProperty("revised_items").EnumerateArray()) entries.TryAdd(Orbit.Domain.Common.AgentOperationFingerprint.Compute("log", item.GetRawText()), item.Clone()); } - resolvedCalls[logs[0].Id] = logs[0] with + if (entries.Count == 0) + return; + var first = logs.First(call => resolvedCalls[call.Id] is not null); + resolvedCalls[first.Id] = first with { Args = JsonSerializer.SerializeToElement(new { revised_items = entries.Values.ToArray() }) }; - foreach (var call in logs.Skip(1)) + foreach (var call in logs.Where(call => call.Id != first.Id)) resolvedCalls[call.Id] = null; } private static void CoalesceTagCalls(Dictionary calls) { - var tags = calls.Values.OfType().Where(call => call.Name == "assign_tags") + var tags = calls.Values.OfType().Where(call => call.Name == "assign_tags" && call.Args.ValueKind == JsonValueKind.Object) .GroupBy(call => { var arguments = JsonNode.Parse(call.Args.GetRawText())!.AsObject(); @@ -196,7 +277,8 @@ private static void CoalesceTagCalls(Dictionary calls) ids.Add(id.GetString()!); if (call.Args.TryGetProperty("habit_ids", out var values) && values.ValueKind == JsonValueKind.Array) foreach (var value in values.EnumerateArray()) - ids.Add(value.GetString()!); + if (value.ValueKind == JsonValueKind.String) + ids.Add(value.GetString()!); } var first = group[0]; var arguments = JsonNode.Parse(first.Args.GetRawText())!.AsObject(); @@ -208,25 +290,54 @@ private static void CoalesceTagCalls(Dictionary calls) } } + private static Habit? ResolveHabitName(ToolExecutionAccumulator results, string name) + { + var resolved = HabitNameMatcher.Resolve(results.Habits, name); + if (resolved is not null) + return resolved; + var matches = results.Habits.Where(habit => HabitNameMatcher.Matches(habit.Title, name)).ToArray(); + if (matches.Length > 1) + results.AmbiguousHabitNames[name] = matches.Select(habit => habit.Title).ToArray(); + else + results.MissingHabitNames.Add(name); + return null; + } + private static ToolCallOutcome ResolvedWithoutWrite(AiToolCall call, ToolExecutionAccumulator results) => new( - new AiToolCallResult(call.Name, call.Id, true, null, null, null, new - { - status = "no_new_preview", - existing_habits = results.Habits.Select(habit => new { habit.Id, habit.Title }).ToArray(), - unresolved_names = results.MissingHabitNames.ToArray(), - instruction = "Reuse existing habits. A repeated create or preview was refused. Ask about unresolved names without creating them." - }), null, null, null, null); + new AiToolCallResult(call.Name, call.Id, !results.InvalidCalls.ContainsKey(call.Id), null, null, + results.InvalidCalls.GetValueOrDefault(call.Id), new + { + status = "no_new_preview", + unresolved_names = results.MissingHabitNames.ToArray(), + ambiguous_names = results.AmbiguousHabitNames, + existing_duplicates = results.ExistingDuplicateNames.ToArray(), + pending_duplicates = results.PendingDuplicateNames.ToArray(), + instruction = "No new preview was prepared. Ask about the returned missing, ambiguous or duplicate names. Ignore unsolicited creates for a tag-only request." + }), null, null, null, null); private static string? AddResolutionQuestion(string? message, ToolExecutionAccumulator results, string? language) { - if (results.MissingHabitNames.Count == 0) + if (!HasResolutionQuestion(results)) return message; - var names = string.Join(", ", results.MissingHabitNames); - var question = Orbit.Application.Common.LocaleHelper.IsPortuguese(language) - ? $"Não encontrei estes hábitos: {names}. Qual hábito você quer usar?" - : $"I couldn't find these habits: {names}. Which habit would you like to use?"; - var previewMessage = Orbit.Application.Common.LocaleHelper.IsPortuguese(language) - ? "Revise a prévia antes de aprovar." : "Review the preview before approving."; - return results.PendingOperations.Count == 0 ? question : previewMessage + "\n\n" + question; + var portuguese = Orbit.Application.Common.LocaleHelper.IsPortuguese(language); + var questions = new List(); + if (results.MissingHabitNames.Count > 0) + { + var names = string.Join(", ", results.MissingHabitNames); + questions.Add(portuguese ? $"Não encontrei estes hábitos: {names}. Qual hábito você quer usar?" + : $"I couldn't find these habits: {names}. Which habit would you like to use?"); + } + foreach (var (name, matches) in results.AmbiguousHabitNames) + questions.Add(portuguese ? $"Para {name}, encontrei: {string.Join(", ", matches)}. Qual hábito você quer usar?" + : $"For {name}, I found: {string.Join(", ", matches)}. Which habit would you like to use?"); + foreach (var name in results.ExistingDuplicateNames) + questions.Add(portuguese ? $"O hábito {name} já existe. Você quer usar esse hábito ou criar outro com um nome diferente?" + : $"The habit {name} already exists. Would you like to use it or create another with a different name?"); + foreach (var name in results.PendingDuplicateNames) + questions.Add(portuguese ? $"{PortuguesePendingQuestion} {name} aguardando. Aprove a prévia ou me diga para preparar uma nova." + : $"{EnglishPendingQuestion} {name} waiting. Approve it, or tell me to prepare a new one."); + if (results.PendingOperations.Count > 0) + questions.Insert(0, portuguese ? "Revise a prévia antes de aprovar." : "Review the preview before approving."); + return string.Join("\n\n", questions); } } diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs index 0a18992ae..c4461d5fe 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs @@ -188,7 +188,7 @@ private async Task> ExecuteToolCall .ToList(); var bulkRedirects = BulkToolRepeatGuard.FindRedirects(writeCalls); var resolvedWrites = writeCalls.ToDictionary(call => call.Id, call => ResolveHabitCall(call, executionResults)); - await CoalesceLogCallsAsync(writeCalls, resolvedWrites, request.UserId, cancellationToken); + await CoalesceLogCallsAsync(writeCalls, resolvedWrites, request.UserId, executionResults, cancellationToken); CoalesceTagCalls(resolvedWrites); var readOnlyTasks = readOnlyCalls @@ -355,9 +355,14 @@ private async Task ExecuteSingleToolCallAsync( item_count = executionResponse.PendingOperation?.ChangeTargetCount, habit_count = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?.Select(item => item.EntityId).Distinct().Count() : (int?)null, date_count = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?.SelectMany(item => item.Fields).Where(field => field.Field == "date").Select(field => field.NewValue).Distinct().Count() : (int?)null, + proposed_titles = executionResponse.PendingOperation?.Items?.Select(item => item.EntityName).Distinct().Take(10).ToArray(), + proposed_values = call.Args.EnumerateObject().Where(property => + property.Value.ValueKind is JsonValueKind.Number || property.Name is "title" or "deadline" or "unit") + .ToDictionary(property => property.Name, property => property.Value.Clone()), resolved_habits = executionResults.Habits.Where(habit => call.Args.GetRawText().Contains(habit.Id.ToString(), StringComparison.OrdinalIgnoreCase)).Select(habit => new { habit.Id, habit.Title }).ToArray(), - items = executionResponse.PendingOperation?.Items, - changes = executionResponse.PendingOperation?.Changes, + dates = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items? + .SelectMany(item => item.Fields).Where(field => field.Field == "date") + .Select(field => field.NewValue).Distinct().Order().ToArray() : null, instruction = "This is a proposal. Nothing has changed until approval. Use only the returned numbers, names, and dates." }), null, diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 1360e73cf..49d679527 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -266,10 +266,11 @@ private async Task> HandleTurnAsync(ProcessUserChatCommand { AnsweredAtUtc = answeredAtUtc, Habits = context.ActiveHabits, + AllHabits = context.UserHabits, IsTaggingIntent = IsTagOnlyIntent(request.Message), - RequiresExistingParent = Regex.IsMatch(request.Message, @"\b(divida|dividir|split|break down)\b", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)) + RequiresExistingParent = RequestsExistingParent(request.Message, context.ActiveHabits) }; - SeedProposedHabitNames(request.UserId, executionResults); + SeedProposedHabitNames(request, executionResults); var actionsStopwatch = System.Diagnostics.Stopwatch.StartNew(); var toolLoopResult = await RunToolCallLoopAsync( response.Value, diff --git a/src/Orbit.Application/Chat/HabitNameMatcher.cs b/src/Orbit.Application/Chat/HabitNameMatcher.cs index eaea5d150..85f764a17 100644 --- a/src/Orbit.Application/Chat/HabitNameMatcher.cs +++ b/src/Orbit.Application/Chat/HabitNameMatcher.cs @@ -24,11 +24,7 @@ public static bool Matches(string title, string search) if (normalized.Length == 0) return false; var candidate = Normalize(title); - if (candidate.Contains(normalized, StringComparison.Ordinal)) - return true; - var words = candidate.Split(' ').Select(CanonicalWord).ToArray(); - return normalized.Split(' ').Select(CanonicalWord) - .All(word => words.Contains(word, StringComparer.Ordinal)); + return (" " + candidate + " ").Contains(" " + normalized + " ", StringComparison.Ordinal); } public static Habit? Resolve(IEnumerable habits, string name) @@ -41,5 +37,4 @@ public static bool Matches(string title, string search) return matches.Count == 1 ? matches[0] : null; } - private static string CanonicalWord(string word) => word is "ler" or "leitura" or "lendo" ? "leitura" : word; } diff --git a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs index 3aba19849..b39f95df2 100644 --- a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs +++ b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs @@ -125,30 +125,37 @@ public sealed class PendingOperationChangePreviewer( var singles = AssignTagsTool.SingleHabitArguments(arguments); if (singles is null) return null; - var items = new List(); - foreach (var single in singles) + var ids = singles.Select(single => Guid.Parse(single.GetProperty("habit_id").GetString()!)).ToArray(); + var habits = await habitRepository.FindAsync(habit => habit.UserId == userId && ids.Contains(habit.Id), + AssignTagsTool.IncludeTags, ct); + if (habits.Count != ids.Length) + return null; + var items = habits.OrderBy(habit => habit.Id).Select(habit => { - var preview = await PreviewOtherAsync(userId, operationId, single, ct); - if (preview?.Items is not { Count: 1 } || preview.Items[0].EntityId is null) - return null; - items.Add(preview.Items[0] with + var single = singles.Single(value => value.GetProperty("habit_id").GetString() == habit.Id.ToString()); + var preview = AgentArgumentPreview.Build(operationId, single, TargetHabit(habit, true), EditableSchema(operationId))!; + return preview.Items![0] with { Fields = preview.Items[0].Fields.Select(field => field with { IsEditable = false }).ToList() - }); - } - return BuildPreview(operationId, items.Take(10).SelectMany(item => item.Fields).ToList(), items); + }; + }).ToList(); + return await BindDependenciesAsync(userId, operationId, arguments, + BuildPreview(operationId, items.Take(10).SelectMany(item => item.Fields).ToList(), items), ct, + bindHabits: false); } - var built = AgentArgumentPreview.Build(operationId, arguments, - await ResolveTargetAsync(userId, arguments, ct), EditableSchema(operationId)); + var target = await ResolveTargetAsync(userId, arguments, ct); + if (target is null && new[] { "habit_id", "goal_id", "tag_id" }.Any(field => arguments.TryGetProperty(field, out _))) + return null; + var built = AgentArgumentPreview.Build(operationId, arguments, target, EditableSchema(operationId)); return await BindDependenciesAsync(userId, operationId, arguments, built, ct); } private async Task BindDependenciesAsync(Guid userId, string operationId, - JsonElement arguments, PendingOperationChangePreview? preview, CancellationToken ct) + JsonElement arguments, PendingOperationChangePreview? preview, CancellationToken ct, bool bindHabits = true) { if (preview?.Items is null) return preview; - var habitIds = DependencyIds(arguments, "habit_ids", "parent_habit_id"); + var habitIds = bindHabits ? DependencyIds(arguments, "habit_ids", "parent_habit_id") : []; var tagIds = DependencyIds(arguments, "tag_ids"); if (habitIds.Count == 0 && tagIds.Count == 0) return preview; @@ -162,9 +169,16 @@ public sealed class PendingOperationChangePreviewer( .Concat(tags.OrderBy(tag => tag.Id).Select(FingerprintTag)).ToArray(); var items = preview.Items.Select(item => item with { + Fields = item.Fields.Select(field => field.Field switch + { + "habit_ids" => field with { NewValue = string.Join(", ", habits.OrderBy(habit => habit.Id).Select(habit => habit.Title)) }, + "parent_habit_id" => field with { NewValue = habits.Single().Title }, + "tag_ids" => field with { NewValue = string.Join(", ", tags.OrderBy(tag => tag.Id).Select(tag => tag.Name)) }, + _ => field + }).ToList(), StateFingerprint = AgentOperationFingerprint.Compute(item.StateFingerprint, JsonSerializer.Serialize(states)) }).ToList(); - return BuildPreview(operationId, preview.Changes, items); + return BuildPreview(operationId, items.Take(10).SelectMany(item => item.Fields).ToList(), items); } private static HashSet DependencyIds(JsonElement arguments, params string[] fields) @@ -240,10 +254,7 @@ private JsonElement EditableSchema(string operationId) => var habit = habits.FirstOrDefault(); if (habit is null) return null; - var values = HabitValues(habit); - if (readsTags) - values["tag_names"] = values["tag_ids"] = FormatTags(habit); - return new AgentPreviewTarget(habit.Id, habit.Title, values, FingerprintHabit(habit)); + return TargetHabit(habit, readsTags); } if (TryReadId(arguments, "goal_id", out var goalId)) @@ -267,6 +278,16 @@ private JsonElement EditableSchema(string operationId) => return null; } + private static AgentPreviewTarget TargetHabit(Habit habit, bool readsTags) + { + var values = HabitValues(habit); + if (readsTags) + values["tag_names"] = values["tag_ids"] = FormatTags(habit); + var fingerprint = readsTags ? AgentOperationFingerprint.Compute(FingerprintHabit(habit), + JsonSerializer.Serialize(habit.Tags.OrderBy(tag => tag.Id).Select(FingerprintTag))) : FingerprintHabit(habit); + return new AgentPreviewTarget(habit.Id, habit.Title, values, fingerprint); + } + private static bool TryReadId(JsonElement arguments, string field, out Guid id) { id = Guid.Empty; @@ -378,11 +399,11 @@ private static void AddProposedFields(List fields, Habit ? arguments.GetProperty("updates") : default; if (proposed.ValueKind != JsonValueKind.Object) return; - foreach (var property in proposed.EnumerateObject()) + foreach (var property in proposed.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal)) { if (fields.Any(field => field.Field == property.Name)) continue; - var value = property.Value.ToString(); + var value = AgentArgumentPreview.FormatValue(property.Value); fields.Add(new PendingOperationChange(habit.Id, habit.Title, property.Name, value, value, "text", property.Value.Clone(), IsEditableUpdateField(property.Name, habit, effective, changes))); @@ -517,6 +538,9 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action Guid.Empty, name, property.Name, null, AgentArgumentPreview.FormatValue(property.Value), "text", property.Value.Clone(), true)).ToList() : []; + if (parent.Count == 1) + fields.Add(new PendingOperationChange(parent[0].Id, name, "parent_habit_id", null, + parent[0].Title, "text", JsonSerializer.SerializeToElement(parent[0].Id), false)); foreach (var error in errors.Where(error => CreateFields.Contains(error.Field))) if (fields.All(field => field.Field != error.Field)) fields.Add(new PendingOperationChange(Guid.Empty, name, error.Field, @@ -528,7 +552,8 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action name, fields, AgentOperationFingerprint.Compute("bulk_create_habits" + parentFingerprint, habit.GetRawText()), RemovesData: false, ValidationErrors: errors.Count > 0 ? errors : null); }).ToList(); - return BuildPreview("bulk_create_habits", [], items); + return BuildPreview("bulk_create_habits", items.SelectMany(item => item.Fields) + .Where(field => field.Field == "parent_habit_id").ToList(), items); } private static PendingOperationChangePreview? BuildPreview( diff --git a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs index e0adae7e4..1563f70b7 100644 --- a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs +++ b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs @@ -119,8 +119,8 @@ public async Task ReviseAsync( public async Task IsCurrentAsync(Guid userId, PendingAgentOperationExecution execution, CancellationToken cancellationToken) { - if (execution.PreviewFingerprint is null) - return true; + if (string.IsNullOrWhiteSpace(execution.PreviewFingerprint)) + return toolRegistry.GetTool(execution.OperationId)?.IsReadOnly == true; var current = await previewer.PreviewAsync(userId, execution.OperationId, execution.Arguments, cancellationToken); return current?.PreviewFingerprint == execution.PreviewFingerprint; diff --git a/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs index 564d72bb9..d0771e043 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/AssignTagsTool.cs @@ -48,19 +48,22 @@ public class AssignTagsTool( var batch = SingleHabitArguments(args); if (batch is null) return Orbit.Domain.Common.Result.Failure("Provide a non-empty habit_ids array without habit_id."); - foreach (var arguments in batch) - { - var checkedItem = await CheckArgumentsAsync(arguments, userId, ct); - if (checkedItem.IsFailure) - return checkedItem; - } - return Orbit.Domain.Common.Result.Success(); + var habitIds = batch.Select(value => Guid.Parse(value.GetProperty("habit_id").GetString()!)).ToArray(); + var batchHabits = await habitRepository.FindAsync(habit => habit.UserId == userId && habitIds.Contains(habit.Id), ct); + if (batchHabits.Count != habitIds.Length) + return Orbit.Domain.Common.Result.Failure("Habit not found."); + return await CheckTagsAsync(args, userId, ct); } if (!HabitToolHelpers.TryParseHabitId(args, out var habitId)) return Orbit.Domain.Common.Result.Failure("Invalid habit_id."); var habits = await habitRepository.FindAsync(h => h.Id == habitId && h.UserId == userId, ct); if (habits.Count == 0) return Orbit.Domain.Common.Result.Failure("Habit not found."); + return await CheckTagsAsync(args, userId, ct); + } + + private async Task CheckTagsAsync(JsonElement args, Guid userId, CancellationToken ct) + { var hasIds = args.TryGetProperty("tag_ids", out var ids); var hasNames = args.TryGetProperty("tag_names", out var names); if (hasIds == hasNames) @@ -114,21 +117,40 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel private async Task AssignBatchAsync(JsonElement args, Guid userId, CancellationToken ct) { - var validation = await CheckArgumentsAsync(args, userId, ct); + var batch = SingleHabitArguments(args); + if (batch is null) + return new ToolResult(false, Error: "Provide a non-empty habit_ids array without habit_id."); + var validation = await CheckTagsAsync(args, userId, ct); if (validation.IsFailure) return ToolResult.FromFailure(validation); - var results = new List(); - foreach (var arguments in SingleHabitArguments(args)!) + var ids = batch.Select(value => Guid.Parse(value.GetProperty("habit_id").GetString()!)).ToArray(); + var habits = await habitRepository.FindTrackedAsync(habit => habit.UserId == userId && ids.Contains(habit.Id), IncludeTags, ct); + if (habits.Count != ids.Length) + return new ToolResult(false, Error: "Habit not found."); + List resolvedTags; + if (args.TryGetProperty("tag_ids", out _)) + { + var tagIds = JsonArgumentParser.ParseGuidArray(args, "tag_ids")!; + resolvedTags = tagIds.Count == 0 ? [] : (await tagRepository.FindTrackedAsync(tag => + tag.UserId == userId && tagIds.Contains(tag.Id), ct)).ToList(); + if (resolvedTags.Count != tagIds.Distinct().Count()) + return new ToolResult(false, Error: "Tag not found."); + } + else + resolvedTags = await HabitToolHelpers.ResolveOrCreateTagsAsync(tagRepository, + args.GetProperty("tag_names").EnumerateArray().Select(value => value.GetString()!), userId, ct); + foreach (var habit in habits) { - var result = await ExecuteAsync(arguments, userId, ct); - if (!result.Success) - return result; - results.Add(result); + foreach (var tag in habit.Tags.ToList()) + habit.RemoveTag(tag); + foreach (var tag in resolvedTags) + habit.AddTag(tag); } - return new ToolResult(true, EntityName: $"{results.Count} habits tagged", Payload: new + await unitOfWork.SaveChangesAsync(ct); + return new ToolResult(true, EntityName: $"{habits.Count} habits tagged", Payload: new { - habit_count = results.Count, - habits = results.Select(result => new { id = result.EntityId, title = result.EntityName }).ToArray() + habit_count = habits.Count, + habits = habits.Select(habit => new { id = habit.Id, title = habit.Title }).ToArray() }); } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs index adbb995df..326ca486f 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitLogArguments.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using Orbit.Application.Habits.Commands; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -10,7 +11,8 @@ namespace Orbit.Application.Chat.Tools.Implementations; internal static class BulkHabitLogArguments { public static async Task> ExpandAsync(JsonElement arguments, - IGenericRepository habits, IUserDateService dates, Guid userId, CancellationToken ct) + IGenericRepository habits, IUserDateService dates, Guid userId, CancellationToken ct, + IReadOnlyList? loadedHabits = null) { if (arguments.TryGetProperty("revised_items", out _)) { @@ -26,6 +28,8 @@ public static async Task> ExpandAsync(JsonElement arguments, if (arguments.TryGetProperty("date", out _) || dateArray.ValueKind != JsonValueKind.Array || dateArray.GetArrayLength() == 0) return Result.Failure("Provide dates as a non-empty array, without date."); + if (dateArray.GetArrayLength() > AppConstants.MaxBulkOperationSize) + return Result.Failure($"Provide at most {AppConstants.MaxBulkOperationSize} dates per preview."); foreach (var date in dateArray.EnumerateArray()) { if (!TryDate(date, out var parsed)) @@ -42,7 +46,9 @@ public static async Task> ExpandAsync(JsonElement arguments, else selectedDates.Add(await dates.GetUserTodayAsync(userId, ct)); - var selectedHabits = await BulkHabitSelection.LoadAsync(habits, userId, filter!, ct); + var selectedHabits = loadedHabits is null + ? await BulkHabitSelection.LoadAsync(habits, userId, filter!, ct) + : BulkHabitSelection.Select(loadedHabits, filter!); if (selectedHabits.Count == 0) return Result.Failure("No matching habits found."); return Result.Success(JsonSerializer.SerializeToElement(new diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs index 7f1b5707e..5e65c4d4a 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs @@ -27,7 +27,8 @@ public object GetParameterSchema() { type = JsonSchemaTypes.Array, items = new { type = JsonSchemaTypes.String }, - description = "All requested dates in YYYY-MM-DD format. Use one call for a date range, omitting excluded dates." + maxItems = Orbit.Application.Common.AppConstants.MaxBulkOperationSize, + description = "At most 100 requested dates in YYYY-MM-DD format. Use one call for a date range, omitting excluded dates." }); return schema; } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs index b595587c7..92fba7d21 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs @@ -156,25 +156,20 @@ private async Task QueryHabitsAsync( var normalizedSearch = NormalizeSearchValue(f.Search); var normalizedTag = NormalizeSearchValue(f.Tag); - var habits = await habitRepository.FindAsync( - h => h.UserId == userId - && (f.IsGeneral == null || h.IsGeneral == f.IsGeneral.Value) - && (f.IsBadHabit == null || h.IsBadHabit == f.IsBadHabit.Value) - && (!f.FrequencyOneTime || h.FrequencyUnit == null) - && (f.Frequency == null || h.FrequencyUnit == f.Frequency.Value) - && (!f.Date.HasValue || !h.IsGeneral), - q => includeMetrics - ? q.Include(h => h.Tags).Include(h => h.Logs).AsSplitQuery() - : q - .Include(h => h.Tags) - .Include(h => h.Logs.Where(l => !l.IsDeleted && l.Date == today && l.Value > 0)) - .AsSplitQuery(), - ct); + var deferMetrics = includeMetrics && (normalizedSearch is not null || normalizedTag is not null); + var habits = await LoadCandidatesAsync(userId, f, today, includeMetrics, deferMetrics, ct); habits = habits.Where(habit => normalizedSearch is null || HabitNameMatcher.Matches(habit.Title, normalizedSearch)) .Where(habit => normalizedTag is null || habit.Tags.Any(tag => HabitNameMatcher.Matches(tag.Name, normalizedTag))) .ToList(); + if (deferMetrics && habits.Count > 0) + { + var ids = habits.Select(habit => habit.Id).ToArray(); + habits = await habitRepository.FindAsync(habit => habit.UserId == userId && ids.Contains(habit.Id), + query => query.Include(habit => habit.Tags).Include(habit => habit.Logs).AsSplitQuery(), ct); + } + if (f.Date is DateOnly date) { var schedule = await HabitTodaySnapshot.ReadAsync(habits, date, weekStartDay, scheduleLogReader, ct); @@ -190,6 +185,25 @@ private async Task QueryHabitsAsync( .ToList(), null); } + private async Task> LoadCandidatesAsync(Guid userId, HabitFilters f, + DateOnly today, bool includeMetrics, bool deferMetrics, CancellationToken ct) + { + return await habitRepository.FindAsync( + h => h.UserId == userId + && (f.IsGeneral == null || h.IsGeneral == f.IsGeneral.Value) + && (f.IsBadHabit == null || h.IsBadHabit == f.IsBadHabit.Value) + && (!f.FrequencyOneTime || h.FrequencyUnit == null) + && (f.Frequency == null || h.FrequencyUnit == f.Frequency.Value) + && (!f.Date.HasValue || !h.IsGeneral), + q => deferMetrics ? q.Include(h => h.Tags) : includeMetrics + ? q.Include(h => h.Tags).Include(h => h.Logs).AsSplitQuery() + : q + .Include(h => h.Tags) + .Include(h => h.Logs.Where(l => !l.IsDeleted && l.Date == today && l.Value > 0)) + .AsSplitQuery(), + ct); + } + private static string? NormalizeSearchValue(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs b/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs index 4a07bcaaf..53310a8b0 100644 --- a/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs +++ b/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs @@ -38,16 +38,23 @@ public static async Task> LoadAsync( query => query.Include(habit => habit.Tags), cancellationToken); + return Select(habits, filter); + } + + internal static IReadOnlyList Select(IEnumerable habits, BulkHabitFilter filter) + { var idSet = filter.HabitIds.ToHashSet(); var tag = filter.Tag?.Trim(); var search = filter.Search?.Trim(); return habits + .Where(habit => filter.IsCompleted.HasValue ? habit.IsCompleted == filter.IsCompleted.Value + : filter.IncludeCompleted || !habit.IsCompleted) .Where(habit => filter.All || idSet.Count == 0 || idSet.Contains(habit.Id)) .Where(habit => string.IsNullOrWhiteSpace(tag) || habit.Tags.Any(candidate => candidate.Name.Equals(tag, StringComparison.OrdinalIgnoreCase))) .Where(habit => string.IsNullOrWhiteSpace(search) - || Orbit.Application.Chat.HabitNameMatcher.Matches(habit.Title, search) + || habit.Title.Contains(search, StringComparison.OrdinalIgnoreCase) || habit.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) .Where(habit => filter.IsGeneral is null || habit.IsGeneral == filter.IsGeneral) .Where(habit => filter.IsBadHabit is null || habit.IsBadHabit == filter.IsBadHabit) diff --git a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs index b4266e980..f5e135768 100644 --- a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs +++ b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs @@ -49,9 +49,12 @@ PendingAgentOperation Create( PendingAgentOperationExecution? GetExecution(Guid userId, Guid pendingOperationId); bool IsAwaitingConfirmation(Guid userId, Guid pendingOperationId); IReadOnlyList GetPendingHabitCreations(Guid userId); + void SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitId); bool Revise(Guid userId, Guid pendingOperationId, string expectedFingerprint, string argumentsJson, string operationFingerprint, string previewFingerprint); bool Cancel(Guid userId, Guid pendingOperationId, string expectedFingerprint); + PendingAgentOperationExecution? GetConfirmedExecution(Guid userId, string capabilityId, + string operationFingerprint, string confirmationToken); bool TryConsumeFreshConfirmation( Guid userId, string capabilityId, diff --git a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs index 02b79b352..d8477343b 100644 --- a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs +++ b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs @@ -76,6 +76,21 @@ public async Task ExecuteAsync( if (ownershipDenialReason is not null) return await DenyOwnershipAsync(execution, ownershipDenialReason, cancellationToken); + if (operation.IsMutation && request.Surface == AgentExecutionSurface.Chat && request.ConfirmationToken is { } token) + { + var confirmed = pendingOperationStore.GetConfirmedExecution(request.UserId, capability.Id, + AgentOperationFingerprint.Compute(operation.Id, arguments.GetRawText()), token); + if (confirmed is not null) + { + var current = await changePreviewer.TryPreviewAsync(request.UserId, operation.Id, + arguments, logger, cancellationToken); + if (string.IsNullOrWhiteSpace(confirmed.PreviewFingerprint) || current.IsFailure + || current.Value?.Items is not { Count: > 0 } + || current.Value.PreviewFingerprint != confirmed.PreviewFingerprint) + return await DenyByPolicyAsync(execution, new AgentPolicyDecision( + AgentPolicyDecisionStatus.Denied, capability, "stale_preview"), cancellationToken); + } + } return await ExecuteWithPolicyAsync(execution, cancellationToken); } @@ -209,7 +224,7 @@ await TryAuditAsync( var pending = policyDecision.PendingOperation; if (pending is not null && execution.Request.Surface == AgentExecutionSurface.Chat - && execution.Request.IncludeChangePreview) + && (execution.Operation.IsMutation || execution.Request.IncludeChangePreview)) { var attemptedPreview = await changePreviewer.TryPreviewAsync( execution.Request.UserId, execution.Operation.Id, execution.Arguments, logger, cancellationToken); @@ -222,38 +237,28 @@ await TryAuditAsync( } var preview = attemptedPreview.Value; - if (preview is { ChangeTargetCount: 0 } || preview?.Items is { Count: 0 } - || preview is null && RequiresMatchedHabits(execution.Operation.Id)) + if (preview is null || preview.ChangeTargetCount is not > 0 || preview.Items is not { Count: > 0 } + || string.IsNullOrWhiteSpace(preview.PreviewFingerprint)) { pendingOperationStore.Cancel(execution.Request.UserId, pending.Id, AgentOperationFingerprint.Compute(execution.Operation.Id, execution.Arguments.GetRawText())); - return await BuildToolOutcomeResponseAsync(execution, policyDecision, new ToolResult(true, - Payload: new - { - status = "no_matching_habits", - instruction = "No approval preview was created. Tell the person which habit names did not resolve and ask which existing habits to use. Do not create habits." - }), cancellationToken); + return await BuildToolOutcomeResponseAsync(execution, policyDecision, + new ToolResult(false, Error: "preview_unavailable"), cancellationToken); } - if (preview is not null) + var argumentsJson = execution.Arguments.GetRawText(); + var operationFingerprint = AgentOperationFingerprint.Compute(execution.Operation.Id, argumentsJson); + if (!pendingOperationStore.Revise(execution.Request.UserId, pending.Id, operationFingerprint, + argumentsJson, operationFingerprint, preview.PreviewFingerprint)) + throw new InvalidOperationException("The pending operation preview binding could not be persisted."); + + pending = pending with { - if (!string.IsNullOrWhiteSpace(preview.PreviewFingerprint)) - { - var argumentsJson = execution.Arguments.GetRawText(); - var operationFingerprint = AgentOperationFingerprint.Compute(execution.Operation.Id, argumentsJson); - if (!pendingOperationStore.Revise(execution.Request.UserId, pending.Id, operationFingerprint, - argumentsJson, operationFingerprint, preview.PreviewFingerprint)) - throw new InvalidOperationException("The pending operation preview binding could not be persisted."); - } - - pending = pending with - { - Changes = preview.Changes, - ChangeTargetCount = preview.ChangeTargetCount, - Items = preview.Items, - PreviewFingerprint = preview.PreviewFingerprint - }; - } + Changes = preview.Changes, + ChangeTargetCount = preview.ChangeTargetCount, + Items = preview.Items, + PreviewFingerprint = preview.PreviewFingerprint + }; } return AgentOperationResponseFactory.ConfirmationRequired( @@ -265,10 +270,6 @@ await TryAuditAsync( pending); } - private static bool RequiresMatchedHabits(string operationId) => operationId is - "bulk_log_habits" or "bulk_skip_habits" or "bulk_update_habits" or "bulk_reschedule_habits" - or "bulk_delete_habits" or "bulk_create_habits"; - private async Task ExecuteWithPolicyAsync( OperationExecutionContext execution, CancellationToken cancellationToken) diff --git a/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs b/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs index 30d47fe0e..6a73942b8 100644 --- a/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs +++ b/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Orbit.Domain.Entities; +using Orbit.Application.Chat; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; using Orbit.Infrastructure.Configuration; @@ -107,20 +108,23 @@ public IReadOnlyList GetPendingHabitCreations(Guid userId) { var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; var proposals = dbContext.PendingAgentOperations.AsNoTracking() - .Where(item => item.UserId == userId && item.ConsumedAtUtc == null && item.ExpiresAtUtc > checkedAtUtc + .Where(item => item.UserId == userId && item.ConsumedAtUtc == null && item.ConfirmedAtUtc == null + && item.ExpiresAtUtc > checkedAtUtc && item.CreatedAtUtc > checkedAtUtc.AddMinutes(-10) && (item.OperationId == "create_habit" || item.OperationId == "bulk_create_habits" || item.OperationId == "create_sub_habit")) .Select(item => item.ArgumentsJson).ToList(); var names = new List(); foreach (var json in proposals) { var arguments = ParseArguments(json); + if (arguments.ValueKind != JsonValueKind.Object) + continue; Guid? parentId = arguments.TryGetProperty("parent_habit_id", out var parent) && parent.ValueKind == JsonValueKind.String && Guid.TryParse(parent.GetString(), out var parsedParent) ? parsedParent : null; if (arguments.TryGetProperty("habits", out var habits) && habits.ValueKind == JsonValueKind.Array) { foreach (var habit in habits.EnumerateArray()) - if (habit.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) + if (habit.ValueKind == JsonValueKind.Object && habit.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) names.Add(new PendingHabitCreation(title.GetString()!, parentId)); } else if (arguments.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) @@ -129,6 +133,31 @@ public IReadOnlyList GetPendingHabitCreations(Guid userId) return names; } + public void SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitId) + { + var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; + var pending = dbContext.PendingAgentOperations.Where(item => item.UserId == userId + && item.ConsumedAtUtc == null && item.ConfirmedAtUtc == null && item.ExpiresAtUtc > checkedAtUtc + && (item.OperationId == "create_habit" || item.OperationId == "bulk_create_habits" || item.OperationId == "create_sub_habit")) + .ToList(); + foreach (var item in pending) + { + var args = ParseArguments(item.ArgumentsJson); + if (args.ValueKind != JsonValueKind.Object) + continue; + var parent = args.TryGetProperty("parent_habit_id", out var value) + && Guid.TryParse(value.ToString(), out var id) ? (Guid?)id : null; + if (parent != parentHabitId) + continue; + var titles = args.TryGetProperty("habits", out var habits) && habits.ValueKind == JsonValueKind.Array + ? habits.EnumerateArray().Where(habit => habit.ValueKind == JsonValueKind.Object) + .Select(habit => habit.TryGetProperty("title", out var name) ? name.ToString() : "") + : args.TryGetProperty("title", out var name) ? [name.ToString()] : Array.Empty(); + if (titles.Any(name => HabitNameMatcher.Normalize(name) == HabitNameMatcher.Normalize(title))) + Cancel(userId, item.Id, item.OperationFingerprint); + } + } + public bool IsAwaitingConfirmation(Guid userId, Guid pendingOperationId) { var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; @@ -179,6 +208,20 @@ public bool Cancel(Guid userId, Guid pendingOperationId, string expectedFingerpr } } + public PendingAgentOperationExecution? GetConfirmedExecution(Guid userId, string capabilityId, + string operationFingerprint, string confirmationToken) + { + var hash = HashToken(confirmationToken); + var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; + var entity = dbContext.PendingAgentOperations.AsNoTracking().FirstOrDefault(item => item.UserId == userId + && item.CapabilityId == capabilityId && item.OperationFingerprint == operationFingerprint + && item.ConfirmationTokenHash == hash && item.ConfirmedAtUtc != null + && item.ConsumedAtUtc == null && item.ExpiresAtUtc > checkedAtUtc); + return entity is null ? null : new PendingAgentOperationExecution(entity.Id, entity.CapabilityId, + entity.OperationId, ParseArguments(entity.ArgumentsJson), entity.Surface, + entity.ConfirmationRequirement, entity.PreviewFingerprint); + } + public bool TryConsumeFreshConfirmation( Guid userId, string capabilityId, diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs index 00f0db4b0..68aa17696 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs @@ -18,9 +18,9 @@ public string Build(PromptContext context) 1. NEVER claim to have performed an action without calling the corresponding tool. If you want to create, update, delete, log, skip, complete, abandon, or link a habit or goal, you MUST call the tool. Never just say "I've done it" in text. 2. CRITICAL LANGUAGE RULE: Detect the language of the user's CURRENT MESSAGE and respond in THAT language. If the user writes in English, respond in English. If they write in Portuguese, respond in Portuguese. The user's message language ALWAYS wins over any other context. 3. Default dates to TODAY when not specified. When user says "tomorrow", "next week", "in 3 days", calculate the correct date relative to today. - 4. DUPLICATE PREVENTION: Before creating a habit or goal, silently check the indexes already provided above. Those indexes are for ACTIVE duplicate checks. A COMPLETED parent may still appear only to preserve the path to an active sub-habit, but COMPLETED entries never block creating a new habit/goal with the same or similar name. If an ACTIVE habit matches the requested name ignoring case, accents, or a partial activity name, use that existing habit for logs, links, tags, and breakdowns. For example, água matches Beber água and leitura matches Ler 10 minutos. Propose a create only when no active match exists and creation was requested. Never repeat a create already proposed in this conversation. For a breakdown, use bulk_create_habits with parent_habit_id and the children in habits; if the parent does not exist, ask which habit to divide. NEVER ask the user for permission to check for duplicates - you already have the list. + 4. DUPLICATE PREVENTION: Before creating a habit or goal, silently check the indexes already provided above. Those indexes are for ACTIVE duplicate checks. A COMPLETED parent may still appear only to preserve the path to an active sub-habit, but COMPLETED entries never block creating a new habit/goal with the same or similar name. For logs, links, tags, and explicitly requested breakdowns, resolve the activity against the existing titles and pass the matching habit ID. The server matches normalized whole words, not synonyms: for a semantic reference such as leitura to Ler 10 minutos, choose the ID from the index or query_habits results rather than passing the alias as a title search. If several habits fit, list them and ask which one. For an explicit create, only an exact title match after normalizing case and accents is a duplicate: Correr may be created alongside Correr 5 km. A duplicate requires a question, never silence. If a tool reports an existing pending preview, name it and ask the person to approve it or request a replacement. Never repeat a create already proposed in this turn. Use parent_habit_id only when the person explicitly asks for children under an existing habit, including a breakdown; show the parent in the preview. A new routine with sub_habits remains a new root. If the requested existing parent is missing, ask which habit to divide. For a tag-only request, tag the named existing habits without proposing any habit creates or asking about activities the person did not mention. NEVER ask the user for permission to check for duplicates - you already have the list. 5. ACTION-ORIENTED: When the user's intent AND structure are clear (e.g., "I need to meditate every day" or "rename my marathon goal"), act immediately by calling the appropriate tool. Do NOT ask for permission. If the structure or schedule is ambiguous (unknown days for a weekly habit, unspecified time, checklist-vs-subhabit unclear, multi-step routine with no clear grouping), ask ONE targeted question first, then act. Never ask more than one question at a time. See the Structuring Strategy section for when to use checklist_items vs sub_habits. - 6. SUB-HABIT AMBIGUITY: If both a parent and sub-habit match, prefer the standalone habit. + 6. SUB-HABIT AMBIGUITY: If both a parent and sub-habit match, name both and ask which one the person means. 7. COMPLETED HABITS: If a one-time habit is marked COMPLETED, do not try to log or update it. If the user asks to create it again, create a NEW habit instead of reusing the completed one. 8. When user asks to perform an action on a habit AND its sub-habits, call the tool for BOTH the parent and each sub-habit separately. 9. NEVER expose internal habit IDs (GUIDs) to the user in your messages. Refer to habits by their title only. @@ -41,7 +41,7 @@ 9. NEVER expose internal habit IDs (GUIDs) to the user in your messages. Refer t 19. EXPLAIN MECHANICS WITH describe_feature. When the user asks how an Orbit mechanic actually works (streaks, freezes, frequencies, XP/levels/achievements, free-vs-pro limits, reminders/notifications, schedule/overdue rules), call describe_feature with the matching feature_key and base your answer on what it returns instead of guessing. 20. BULK ACTIONS: A request affecting multiple habits must use one matching bulk tool. Never loop update_habit, log_habit, skip_habit, or delete_habit across entities. Use a server-side filter for all, tag, text, status, or frequency scopes. For rescheduling use bulk_reschedule_habits. For other field or schedule changes use bulk_update_habits. 21. COMPLETENESS: Never say all, every, complete, or fully done unless the bulk tool reports partial=false and applied_count equals total_matched. State applied_count, total_matched, and skipped_count when they are returned. If partial=true, explicitly say the operation was partial and do not imply the remainder changed. - 22. APPROVAL FACTS: awaiting_confirmation tool results describe a proposal, never a completed write. Use only their returned item_count, items, changes, numbers and dates in the reply. Do not repeat a held tool call. For logging across dates, send one bulk_log_habits call with habit_ids and dates containing only the requested dates, excluding exceptions. When some names are missing, preview only the matches and ask about the missing names once. For a goal with habit links, pass habit_ids in create_goal. + 22. APPROVAL FACTS: awaiting_confirmation tool results describe a proposal, never a completed write. Use only their returned item_count, habit_count, date_count, proposed_values, proposed_titles, resolved_habits and dates in the reply. Do not repeat a held tool call. For logging across dates, send one bulk_log_habits call with habit_ids and dates containing only the requested dates, excluding exceptions. When some names are missing, preview only the matches and ask about the missing names once. For a goal with habit links, pass habit_ids in create_goal. 23. COPYABLE CONTENT: In English and Portuguese replies, use one fenced block (three backticks: ```, no language tag needed) only for code, a command, or ready-to-paste text (a message, list, or template) the user will copy and use elsewhere. Keep everything else in normal prose; never wrap your whole reply, a single word, or an explanation in a block. """); return sb.ToString(); diff --git a/tests/Orbit.Application.Tests/Chat/AstraApprovalPathTests.cs b/tests/Orbit.Application.Tests/Chat/AstraApprovalPathTests.cs new file mode 100644 index 000000000..221635e89 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/AstraApprovalPathTests.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using FluentAssertions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Application.Chat; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Chat.Validators; +using Orbit.Application.Common; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Application.Tests.Chat; + +public sealed class AstraApprovalPathTests : IDisposable +{ + private readonly OrbitDbContext _db = new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + private readonly Guid _userId; + private readonly IUserDateService _dates = Substitute.For(); + private readonly DateOnly _today = new(2026, 10, 19); + + public AstraApprovalPathTests() + { + var user = User.Create("Alex", "alex@example.com").Value; + user.GrantLifetimePro(); + _userId = user.Id; + _db.Users.Add(user); + _dates.GetUserTodayAsync(_userId, Arg.Any()).Returns(_today); + _db.SaveChanges(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RoundThree_GoalLink_RealApprovalChecksDependency(bool changeLinked) + { + var linked = AddHabit("Ler 10 minutos"); + var unrelated = AddHabit("Beber água"); + var unit = new UnitOfWork(_db, new DatabaseConnectionSettings()); + var tool = new CreateGoalTool(new GenericRepository(_db), unit, new GenericRepository(_db)); + var (executor, store, revisions) = Build(tool); + var args = JsonSerializer.SerializeToElement(new + { + title = "Ler 12 livros", + target_value = 12, + unit = "livros", + deadline = "2026-12-31", + habit_ids = new[] { linked.Id } + }); + var held = await executor.ExecuteAsync(new(_userId, tool.Name, args, AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, IncludeChangePreview: true)); + held.PendingOperation.Should().NotBeNull(JsonSerializer.Serialize(held)); + held.PendingOperation!.Items!.Single().Fields.Should().Contain(field => + field.Field == "habit_ids" && field.NewValue!.Contains("Ler 10 minutos")); + _db.Goals.Should().BeEmpty(); + (changeLinked ? linked : unrelated).PostponeTo(_today.AddDays(1)); + await _db.SaveChangesAsync(); + _db.ChangeTracker.Clear(); + var pending = store.GetExecution(_userId, held.PendingOperation!.Id)!; + (await revisions.IsCurrentAsync(_userId, pending, default)).Should().Be(!changeLinked); + if (changeLinked) + return; + var confirmation = store.Confirm(_userId, held.PendingOperation!.Id)!; + var approved = await executor.ExecuteAsync(new(_userId, tool.Name, pending.Arguments, + AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, ConfirmationToken: confirmation.ConfirmationToken)); + approved.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + _db.ChangeTracker.Clear(); + (await _db.Goals.Include(goal => goal.Habits).SingleAsync()).Habits.Should().ContainSingle().Which.Id.Should().Be(linked.Id); + } + + [Fact] + public async Task RoundThree_Breakdown_RealApprovalCreatesChildrenUnderVisibleParent() + { + var parent = AddHabit("Academia"); + var unrelated = AddHabit("Beber água"); + var habits = new GenericRepository(_db); + var tags = new GenericRepository(_db); + var unit = new UnitOfWork(_db, new DatabaseConnectionSettings()); + var payGate = Substitute.For(); + payGate.CanCreateSubHabits(_userId, Arg.Any()).Returns(Result.Success()); + var config = Substitute.For(); + config.GetAsync(AppConfigKeys.MaxHabitDepth, AppConstants.MaxHabitDepth, Arg.Any()).Returns(AppConstants.MaxHabitDepth); + var handler = new CreateSubHabitCommandHandler(habits, tags, payGate, _dates, unit, config, _cache); + var mediator = Substitute.For(); + mediator.Send(Arg.Any(), Arg.Any()) + .Returns(call => handler.Handle(call.Arg(), call.Arg())); + var tool = new BulkCreateHabitsTool(mediator); + var (executor, store, revisions) = Build(tool); + var args = JsonSerializer.SerializeToElement(new + { + parent_habit_id = parent.Id, + habits = new[] { new { title = "Aquecimento" }, new { title = "Treino" }, new { title = "Alongamento" } } + }); + var held = await executor.ExecuteAsync(new(_userId, tool.Name, args, AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, IncludeChangePreview: true)); + held.PendingOperation.Should().NotBeNull(JsonSerializer.Serialize(held)); + var preview = held.PendingOperation!; + preview.Items.Should().HaveCount(3).And.OnlyContain(item => item.Fields.Any(field => + field.Field == "parent_habit_id" && field.NewValue == "Academia")); + _db.Habits.Should().HaveCount(2); + unrelated.PostponeTo(_today.AddDays(1)); + await _db.SaveChangesAsync(); + _db.ChangeTracker.Clear(); + var pending = store.GetExecution(_userId, preview.Id)!; + (await revisions.IsCurrentAsync(_userId, pending, default)).Should().BeTrue(); + (await revisions.CanApproveAsync(_userId, pending, default)).Should().BeTrue(); + var confirmation = store.Confirm(_userId, preview.Id)!; + var approved = await executor.ExecuteAsync(new(_userId, tool.Name, pending.Arguments, + AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, ConfirmationToken: confirmation.ConfirmationToken)); + approved.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + _db.ChangeTracker.Clear(); + (await _db.Habits.Where(habit => habit.ParentHabitId == parent.Id).Select(habit => habit.Title).ToListAsync()) + .Should().BeEquivalentTo("Aquecimento", "Treino", "Alongamento"); + } + + [Theory] + [InlineData("create_goal")] + [InlineData("assign_tags")] + public async Task RoundThree_DeletedDependency_NeverOffersUnboundApproval(string operationId) + { + var habit = AddHabit("Academia"); + habit.SoftDelete(); + await _db.SaveChangesAsync(); + _db.ChangeTracker.Clear(); + IAiTool tool = operationId == "create_goal" + ? new CreateGoalTool(new GenericRepository(_db), new UnitOfWork(_db, new DatabaseConnectionSettings()), new GenericRepository(_db)) + : new AssignTagsTool(new GenericRepository(_db), new GenericRepository(_db), new UnitOfWork(_db, new DatabaseConnectionSettings())); + var (executor, store, _) = Build(tool); + var args = operationId == "create_goal" + ? JsonSerializer.SerializeToElement(new { title = "Goal", habit_ids = new[] { habit.Id } }) + : JsonSerializer.SerializeToElement(new { habit_ids = new[] { habit.Id }, tag_names = new[] { "Saúde" } }); + + var result = await executor.ExecuteAsync(new(_userId, operationId, args, AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, IncludeChangePreview: true)); + + result.PendingOperation.Should().BeNull(); + store.GetPendingHabitCreations(_userId).Should().BeEmpty(); + _db.PendingAgentOperations.Should().OnlyContain(item => item.ConsumedAtUtc != null); + } + + [Fact] + public async Task RoundThree_DirectApproval_RejectsStaleDependency() + { + var linked = AddHabit("Ler 10 minutos"); + var tool = new CreateGoalTool(new GenericRepository(_db), new UnitOfWork(_db, new DatabaseConnectionSettings()), + new GenericRepository(_db)); + var (executor, store, _) = Build(tool); + var args = JsonSerializer.SerializeToElement(new { title = "Ler livros", habit_ids = new[] { linked.Id } }); + var held = await executor.ExecuteAsync(new(_userId, tool.Name, args, AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, IncludeChangePreview: true)); + linked.PostponeTo(_today.AddDays(1)); + await _db.SaveChangesAsync(); + _db.ChangeTracker.Clear(); + var confirmation = store.Confirm(_userId, held.PendingOperation!.Id)!; + + var approved = await executor.ExecuteAsync(new(_userId, tool.Name, args, AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, ConfirmationToken: confirmation.ConfirmationToken)); + + approved.Operation.Status.Should().NotBe(AgentOperationStatus.Succeeded); + _db.Goals.Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MissingFingerprint_RefusesWritesAndPreservesReadOnlyConfirmation(bool readOnly) + { + var tool = Substitute.For(); + tool.Name.Returns("test_operation"); + tool.IsReadOnly.Returns(readOnly); + var registry = new AiToolRegistry([tool]); + var revisions = new PendingOperationRevisionService(Substitute.For(), + Substitute.For(), new RevisePendingOperationRequestValidator(), registry); + var execution = new PendingAgentOperationExecution(Guid.NewGuid(), "test", tool.Name, + JsonSerializer.SerializeToElement(new { }), AgentExecutionSurface.Chat, AgentConfirmationRequirement.FreshConfirmation); + + (await revisions.IsCurrentAsync(_userId, execution, default)).Should().Be(readOnly); + } + + private Habit AddHabit(string title) + { + var habit = Habit.Create(new(_userId, title, FrequencyUnit.Day, 1, _today)).Value; + _db.Habits.Add(habit); + _db.SaveChanges(); + return habit; + } + + private (AgentOperationExecutor, PendingAgentOperationStore, PendingOperationRevisionService) Build(IAiTool tool) + { + var registry = new AiToolRegistry([tool]); + var catalog = new AgentCatalogService([tool]); + foreach (var key in catalog.GetCapabilities().SelectMany(capability => capability.FeatureFlagKeys ?? []).Distinct()) + _db.AppFeatureFlags.Add(AppFeatureFlag.Create(key, true, null, key)); + _db.SaveChanges(); + var settings = Options.Create(new AgentPlatformSettings()); + var store = new PendingAgentOperationStore(_db, settings); + var bridge = Substitute.For(); + bridge.GetRequiredConfirmationAsync(Arg.Any(), Arg.Any()).Returns((AgentConfirmationRequirement?)null); + var ownership = Substitute.For(); + ownership.GetDenialReasonAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns((string?)null); + var policy = new AgentPolicyEvaluator(_db, catalog, store, bridge, settings, new FeatureFlagService(_db, _cache)); + var previewer = new PendingOperationChangePreviewer(new GenericRepository(_db), + new GenericRepository(_db), new GenericRepository(_db), _dates, registry); + var unit = Substitute.For(); + unit.ExecuteInTransactionAsync(Arg.Any>>(), Arg.Any()) + .Returns(call => call.Arg>>()(call.Arg())); + var executor = new AgentOperationExecutor(catalog, policy, Substitute.For(), + ownership, bridge, registry, previewer, store, unit, + NullLogger.Instance); + return (executor, store, new PendingOperationRevisionService(store, previewer, + new RevisePendingOperationRequestValidator(), registry)); + } + + public void Dispose() + { + _cache.Dispose(); + _db.Dispose(); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs index 45ec8e638..6e3f11b5e 100644 --- a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs +++ b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs @@ -27,6 +27,45 @@ public PendingOperationChangePreviewerTests() => _userDateService.GetUserTodayAsync(UserId, Arg.Any()) .Returns(new DateOnly(2026, 9, 25)); + [Fact] + public async Task RoundThree_BulkDelete_PreservesLiteralSearchSemantics() + { + Setup([CreateHabit("Beber água", null)]); + (await Preview("bulk_delete_habits", """{"filter":{"search":"agua"}}""")).Should().BeNull(); + (await Preview("bulk_delete_habits", """{"filter":{"search":"água"}}""")).Should().NotBeNull(); + } + + [Fact] + public async Task RoundThree_UnchangedUpdateFields_JsonOrderDoesNotInvalidateApproval() + { + Setup([CreateHabit("Read", null)]); + var first = await Preview("bulk_update_habits", """{"filter":{"all":true},"updates":{"title":"Read","description":null}}"""); + var reordered = await Preview("bulk_update_habits", """{"updates":{"description":null,"title":"Read"},"filter":{"all":true}}"""); + reordered!.PreviewFingerprint.Should().Be(first!.PreviewFingerprint); + } + + [Fact] + public async Task RoundThree_CreateChildren_ShowsParentInEveryItem() + { + var parent = CreateHabit("Academia", null); + _habits.FindAsync(Arg.Any>>(), Arg.Any()).Returns(new[] { parent }); + var preview = await Preview("bulk_create_habits", JsonSerializer.Serialize(new + { + parent_habit_id = parent.Id, + habits = new[] { new { title = "Treino" } } + })); + preview!.Items!.Single().Fields.Should().Contain(field => field.Field == "parent_habit_id" + && field.NewValue == "Academia" && !field.IsEditable); + preview.Changes.Should().Contain(field => field.Field == "parent_habit_id" && field.NewValue == "Academia"); + } + + [Theory] + [InlineData("Alergia", "ler", false)] + [InlineData("Ler 10 minutos", "leitura", false)] + [InlineData("Beber água", "AGUA", true)] + public void RoundThree_NameMatching_UsesNormalizedWholeWords(string title, string search, bool matches) => + HabitNameMatcher.Matches(title, search).Should().Be(matches); + [Fact] public async Task PreviewAsync_Create_JsonPropertyOrderDoesNotChangeApproval() { diff --git a/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs index 5b3fa8d30..4f175fe80 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/AssignTagsToolTests.cs @@ -25,6 +25,27 @@ public AssignTagsToolTests() _tool = new AssignTagsTool(_habitRepo, _tagRepo, _unitOfWork); } + [Fact] + public async Task RoundThree_TagBatch_LoadsAndSavesOnce() + { + var habits = new[] { CreateHabit("One"), CreateHabit("Two") }; + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any()).Returns(habits); + _habitRepo.FindTrackedAsync(Arg.Any>>(), + Arg.Any, IQueryable>?>(), Arg.Any()).Returns(habits); + foreach (var habit in habits) + _habitRepo.FindOneTrackedAsync(Arg.Is>>(predicate => predicate.Compile()(habit)), + Arg.Any, IQueryable>?>(), Arg.Any()).Returns(habit); + var arguments = JsonSerializer.SerializeToElement(new { habit_ids = habits.Select(habit => habit.Id).ToArray(), tag_names = new[] { "Saúde" } }); + + var result = await _tool.ExecuteAsync(arguments, UserId, default); + + result.Success.Should().BeTrue(); + habits.Should().OnlyContain(habit => habit.Tags.Count == 1); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + await _habitRepo.Received(1).FindTrackedAsync(Arg.Any>>(), + Arg.Any, IQueryable>?>(), Arg.Any()); + } + [Fact] public async Task AssignTags_ReplacesExistingTags() { diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ChatWriteHoldHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ChatWriteHoldHandlerTests.cs index 120e6fcf9..3c83c7e17 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ChatWriteHoldHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ChatWriteHoldHandlerTests.cs @@ -184,8 +184,8 @@ public async Task Handle_ClientWithNoPreviewSupport_StillHoldsTheWrite() _userId, "Crie o habito"), CancellationToken.None); var pending = result.Value.PendingOperations.Should().ContainSingle().Subject; - pending.Items.Should().BeNull(); - pending.PreviewFingerprint.Should().BeNull(); + pending.Items.Should().ContainSingle(); + pending.PreviewFingerprint.Should().NotBeNullOrWhiteSpace(); await _habitRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); } diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs index 2d62fcef4..9ee1ce2bf 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs @@ -25,7 +25,7 @@ public async Task Replay_GoalWithReadingLink_ResolvesAndCreatesLinkedGoal() var handler = CreateHandler(goal); var held = HoldReplayWrites(goal); _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) - .Returns(ReplayCall("create_goal", """{"title":"Ler 12 livros","target_value":12,"unit":"livros","deadline":"2026-12-31","habit_ids":["leitura"]}""")); + .Returns(ReplayCall("create_goal", """{"title":"Ler 12 livros","target_value":12,"unit":"livros","deadline":"2026-12-31","habit_ids":["Ler 10 minutos"]}""")); ReplyToReplay("Revise a meta antes de aprovar."); var result = await handler.Handle(new(UserId, "Crie uma meta de ler 12 livros até dezembro e ligue ao hábito de leitura."), default); @@ -44,7 +44,7 @@ public async Task Replay_LogsMatchingHabitsOnSixDates_OnePreviewAndMissingNameQu new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); var handler = CreateHandler(log); var held = HoldReplayWrites(log); - const string arguments = """{"habit_ids":["água","leitura","meditação"],"dates":["2026-03-28","2026-03-29","2026-03-30","2026-03-31","2026-04-01","2026-04-03"]}"""; + const string arguments = """{"habit_ids":["água","Ler 10 minutos","meditação"],"dates":["2026-03-28","2026-03-29","2026-03-30","2026-03-31","2026-04-01","2026-04-03"]}"""; _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) .Returns(Result.Success(new AiResponse { ToolCalls = [new("bulk_log_habits", "log1", ParseArguments(arguments)), new("bulk_log_habits", "log2", ParseArguments(arguments))], ConversationContext = new() })); ReplyToReplay("Revise os registros."); @@ -98,6 +98,7 @@ public async Task Replay_HealthTag_DoesNotRecreateExistingHabits() result.Value.PendingOperations.Should().ContainSingle(); held.Should().ContainSingle().Which.OperationId.Should().Be("assign_tags"); held[0].Arguments.GetProperty("habit_id").GetString().Should().Be(habits[2].Id.ToString()); + result.Value.AiMessage.Should().NotContain("Meditação").And.NotContain("Água"); } [Fact] @@ -117,8 +118,10 @@ public async Task Replay_PreviewReturnsNumericFactsToModel() Orbit.Application.Chat.Grounding.GroundednessChecker.Check(result.Value.AiMessage, result.Value.GroundingFacts).Should().BeEmpty(); } - [Fact] - public async Task Replay_UnresolvedNames_NoEmptyPreviewAndOneQuestion() + [Theory] + [InlineData("""{"habit_ids":["meditação"]}""")] + [InlineData("""{"filter":{"search":"meditação"}}""")] + public async Task Replay_UnresolvedNames_NoEmptyPreviewAndOneQuestion(string arguments) { SetupResolutionAccount(); var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, @@ -126,7 +129,7 @@ public async Task Replay_UnresolvedNames_NoEmptyPreviewAndOneQuestion() var handler = CreateHandler(log); var held = HoldReplayWrites(log); _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) - .Returns(ReplayCall("bulk_log_habits", """{"habit_ids":["meditação"]}""")); + .Returns(ReplayCall("bulk_log_habits", arguments)); ReplyToReplay("Escolha um hábito."); var result = await handler.Handle(new(UserId, "Registre meditação."), default); @@ -138,7 +141,7 @@ public async Task Replay_UnresolvedNames_NoEmptyPreviewAndOneQuestion() [Theory] [InlineData("Novo", "NOVO")] - [InlineData("Praticar yoga", "Yoga")] + [InlineData("Praticar yoga", "PRATICAR YOGA")] public async Task Replay_PreviousCreateProposal_IsRefusedAcrossTurns(string previousName, string proposedName) { SetupResolutionAccount(); @@ -188,7 +191,7 @@ public async Task Replay_LogPreview_GroundsHabitAndDateCounts() var handler = CreateHandler(log); HoldReplayWrites(log); _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) - .Returns(ReplayCall("bulk_log_habits", """{"habit_ids":["água","leitura"],"dates":["2026-03-28","2026-03-29","2026-03-30","2026-03-31","2026-04-01","2026-04-03"]}""")); + .Returns(ReplayCall("bulk_log_habits", """{"habit_ids":["água","Ler 10 minutos"],"dates":["2026-03-28","2026-03-29","2026-03-30","2026-03-31","2026-04-01","2026-04-03"]}""")); ReplyToReplay("Revise 12 registros de 2 hábitos em 6 datas."); var result = await handler.Handle(new(UserId, "Registre água e leitura nos últimos sete dias, menos ontem."), default); @@ -211,7 +214,7 @@ public async Task Replay_SixSeparateDateCalls_ProducesOneApproval() ToolCalls = Enumerable.Range(0, 7).Where(offset => offset != 1).Select(offset => new AiToolCall("bulk_log_habits", "date" + offset, JsonSerializer.SerializeToElement(new { - habit_ids = new[] { "água", "leitura" }, + habit_ids = new[] { "água", "Ler 10 minutos" }, date = Today.AddDays(-offset).ToString("yyyy-MM-dd") }))).ToArray(), ConversationContext = new() @@ -259,6 +262,201 @@ public async Task Replay_DuplicateNamesInCreateBatch_AreRefused() held.Should().ContainSingle(); } + [Theory] + [InlineData("Crie Rotina com aquecimento.", "Rotina", "Rotina da manhã")] + [InlineData("Crie Rotina com sub-hábitos de aquecimento.", "Rotina", "Rotina da manhã")] + [InlineData("Crie o hábito Correr com a tag Saúde.", "Correr", "Correr 5 km")] + [InlineData("Cria um hábito Correr com a tag Saúde.", "Correr", "Correr 5 km")] + public async Task RoundThree_ExplicitCreate_DoesNotBecomeExistingParent(string message, string title, string existing) + { + SetupResolutionAccount(); + var matching = new[] { CreateHabit(existing) }; + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any()).Returns(matching); + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any, IQueryable>?>(), Arg.Any()).Returns(matching); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", JsonSerializer.Serialize(new { habits = new[] { new { title, sub_habits = new[] { new { title = "Aquecimento" } } } } }))); + ReplyToReplay("Revise a prévia."); + + var result = await handler.Handle(new(UserId, message), default); + + held.Should().ContainSingle(); + held[0].Arguments.TryGetProperty("parent_habit_id", out _).Should().BeFalse(); + held[0].Arguments.GetProperty("habits")[0].GetProperty("title").GetString().Should().Be(title); + } + + [Fact] + public async Task RoundThree_AmbiguousHabit_ListsCandidatesAndDoesNotWrite() + { + SetupResolutionAccount(); + var matching = new[] { CreateHabit("Água de manhã"), CreateHabit("Água de noite") }; + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any()).Returns(matching); + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any, IQueryable>?>(), Arg.Any()).Returns(matching); + var tags = new AssignTagsTool(_habitRepo, _tagRepo, _unitOfWork); + var handler = CreateHandler(tags); + var held = HoldReplayWrites(tags); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("assign_tags", """{"habit_id":"água","tag_names":["Saúde"]}""")); + ReplyToReplay("Qual hábito?"); + + var result = await handler.Handle(new(UserId, "Atribua Saúde a água."), default); + + held.Should().BeEmpty(); + result.Value.AiMessage.Should().Contain("Água de manhã").And.Contain("Água de noite").And.NotContain("Não encontrei"); + } + + [Theory] + [InlineData("Prepare uma nova prévia para criar Novo.")] + [InlineData("Prepare uma nova.")] + [InlineData("Prepare a new one.")] + public async Task RoundThree_PendingDuplicate_AsksAndNextExplicitRequestCanReplaceIt(string replacementRequest) + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation("Novo")]); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"NOVO"}]}""")); + ReplyToReplay("Revise a prévia."); + + var first = await handler.Handle(new(UserId, "Crie Novo."), default); + + held.Should().BeEmpty(); + first.Value.AiMessage.Should().Contain("Novo").And.Contain("prévia").And.Contain("Aprove"); + var next = await handler.Handle(new(UserId, replacementRequest, + History: [new ChatHistoryMessage("assistant", first.Value.AiMessage!)]), default); + held.Should().ContainSingle(); + next.Value.PendingOperations.Should().ContainSingle(); + } + + [Fact] + public async Task RoundThree_ExistingExactDuplicate_AsksInsteadOfSilentlyRefusing() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"ACADEMIA"}]}""")); + ReplyToReplay("Tudo certo."); + + var result = await handler.Handle(new(UserId, "Crie Academia."), default); + + result.Value.PendingOperations.Should().BeEmpty(); + result.Value.AiMessage.Should().Contain("Academia").And.Contain("?"); + } + + [Theory] + [InlineData("[]")] + [InlineData("{\"habit_ids\":[7],\"tag_names\":[\"Saúde\"]}")] + public async Task RoundThree_MalformedModelArguments_ReturnToolError(string arguments) + { + SetupResolutionAccount(); + var tags = new AssignTagsTool(_habitRepo, _tagRepo, _unitOfWork); + var handler = CreateHandler(tags); + var held = HoldReplayWrites(tags); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("assign_tags", arguments)); + ReplyToReplay("Tente novamente."); + + var result = await handler.Handle(new(UserId, "Atribua Saúde à academia."), default); + + result.IsSuccess.Should().BeTrue(); + held.Should().BeEmpty(); + await _aiIntentService.Received().ContinueWithToolResultsAsync(Arg.Any(), + Arg.Is>(values => values.Any(value => !value.Success)), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task RoundThree_LogCoalescing_UsesTurnSnapshotAndReturnsCompactFacts() + { + SetupResolutionAccount(); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var handler = CreateHandler(log); + HoldReplayWrites(log); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result.Success(new AiResponse + { + ToolCalls = Enumerable.Range(1, 6).Select(day => new AiToolCall("bulk_log_habits", "log" + day, + JsonSerializer.SerializeToElement(new { habit_ids = new[] { "água" }, date = $"2026-04-0{day}" }))).ToArray(), + ConversationContext = new() + })); + ReplyToReplay("Revise os registros."); + + var result = await handler.Handle(new(UserId, "Registre água."), default); + + result.Value.PendingOperations.Should().ContainSingle(); + await _habitRepo.Received(2).FindAsync(Arg.Any>>(), + Arg.Any, IQueryable>?>(), Arg.Any()); + await _aiIntentService.Received().ContinueWithToolResultsAsync(Arg.Any(), + Arg.Is>(values => values.Where(value => value.Payload != null) + .All(value => !JsonSerializer.SerializeToElement(value.Payload).EnumerateObject().Any(property => property.Name == "items"))), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task RoundThree_ExcessiveLogDates_ReturnClearError() + { + SetupResolutionAccount(); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var arguments = JsonSerializer.SerializeToElement(new + { + habit_ids = new[] { Guid.NewGuid().ToString() }, + dates = Enumerable.Range(0, 101).Select(offset => Today.AddDays(-offset).ToString("yyyy-MM-dd")).ToArray() + }); + + var result = await log.CheckArgumentsAsync(arguments, UserId, default); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("100"); + await _habitRepo.DidNotReceiveWithAnyArgs().FindAsync(default!, default(Func, IQueryable>), default); + } + + [Fact] + public async Task ExplicitChildrenUnderNamedParent_UsesExistingParent() + { + var habits = SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"parent_habit_id":"Academia","habits":[{"title":"Aquecimento"}]}""")); + ReplyToReplay("Revise a prévia."); + + var result = await handler.Handle(new(UserId, "Crie sub-hábitos de aquecimento no hábito Academia."), default); + + result.Value.PendingOperations.Should().ContainSingle(); + held.Should().ContainSingle().Which.Arguments.GetProperty("parent_habit_id").GetString().Should().Be(habits[2].Id.ToString()); + } + + [Fact] + public async Task Replay_LogSelectionRetainsCompletedHabitsFromTurnSnapshot() + { + var habits = SetupResolutionAccount().Append(CreateHabit("Concluído", true)).ToArray(); + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any, IQueryable>?>(), Arg.Any()) + .Returns(call => habits.Where(call.Arg>>().Compile()).ToList()); + _habitRepo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(call => habits.Where(call.Arg>>().Compile()).ToList()); + var log = new BulkLogHabitsTool(_mediator, _habitRepo, _userDateService, + new BulkHabitReplayPlanner(Substitute.For(), Substitute.For(), _unitOfWork)); + var handler = CreateHandler(log); + var held = HoldReplayWrites(log); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_log_habits", JsonSerializer.Serialize(new { habit_ids = new[] { habits[^1].Id }, date = Today.ToString("yyyy-MM-dd") }))); + ReplyToReplay("Revise os registros."); + + var result = await handler.Handle(new(UserId, "Registre Concluído."), default); + + result.Value.PendingOperations.Should().ContainSingle().Which.Items.Should().ContainSingle(); + held.Should().ContainSingle(); + } + private Habit[] SetupResolutionAccount() { var user = User.Create("Test", "test@example.com").Value; diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs index 615a5987c..2d526988f 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs @@ -303,7 +303,7 @@ public async Task PendingOperation_InvalidBulkCreateCannotConfirmOrExecute(bool [Theory] [InlineData(false)] [InlineData(true)] - public async Task PendingOperation_ValidBulkCreateCanConfirmAndExecute(bool hasFingerprint) + public async Task PendingOperation_ValidBulkCreateExecutesOnlyWithStoredFingerprint(bool hasFingerprint) { var (controller, id) = await SetupBulkCreateAsync( """{"habits":[{"title":"Valid","frequency_unit":"Day","frequency_quantity":1}]}""", @@ -314,6 +314,12 @@ public async Task PendingOperation_ValidBulkCreateCanConfirmAndExecute(bool hasF new AiController.ExecutePendingOperationRequest("agc_token"), CancellationToken.None); confirmation.Should().BeOfType(); + if (!hasFingerprint) + { + execution.Should().BeOfType(); + await _operationExecutor.DidNotReceiveWithAnyArgs().ExecuteAsync(default!, default); + return; + } execution.Should().BeOfType(); _pendingOperationStore.Received(1).Confirm(UserId, id); await _operationExecutor.Received(1).ExecuteAsync( @@ -475,7 +481,10 @@ public async Task ExecutePendingOperation_UsesStoredPayload() "delete_habit", argsDocument.RootElement.Clone(), AgentExecutionSurface.Chat, - AgentConfirmationRequirement.FreshConfirmation)); + AgentConfirmationRequirement.FreshConfirmation, PreviewFingerprint: "prepared")); + _changePreviewer.PreviewAsync(UserId, "delete_habit", Arg.Any(), Arg.Any()) + .Returns(new PendingOperationChangePreview([], 1, + [new PendingOperationItem("habit-123", null, "Exercise", [], "state")], "prepared")); var response = new AgentExecuteOperationResponse(new AgentOperationResult( "delete_habit", diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs index 5cd67bea2..8e392c62c 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs @@ -338,7 +338,7 @@ public async Task ExecutePendingOperation_InitialPreview_BindsApprovalToTargetSe _dbContext.ChangeTracker.Clear(); var execution = _pendingOperationStore.GetExecution(_userId, pending.Id)!; - var shouldExecute = !includePreview || !addHabit; + var shouldExecute = !addHabit; (await revisions.IsCurrentAsync(_userId, execution, CancellationToken.None)) .Should().Be(shouldExecute); execution.PreviewFingerprint.Should().Be(pending.PreviewFingerprint); @@ -491,10 +491,13 @@ public async Task ReviseDateRange_KeepingOneDate_PreservesTheOfferedItem() var previewer = new PendingOperationChangePreviewer(new GenericRepository(_dbContext), new GenericRepository(_dbContext), new GenericRepository(_dbContext), Substitute.For(), tools); var executor = CreateExecutor(tools, previewer); - var arguments = JsonSerializer.SerializeToElement(new { revised_items = new[] + var arguments = JsonSerializer.SerializeToElement(new + { + revised_items = new[] { new { habit_id = habit.Id, date = "2026-10-18" }, new { habit_id = habit.Id, date = "2026-10-19" } - } }); + } + }); var held = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, "bulk_log_habits", arguments, AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); var pending = held.PendingOperation!; @@ -512,6 +515,44 @@ public async Task ReviseDateRange_KeepingOneDate_PreservesTheOfferedItem() (await revisions.IsCurrentAsync(_userId, execution, default)).Should().BeTrue(); } + [Theory] + [InlineData("create_goal")] + [InlineData("assign_tags")] + public async Task RoundThree_UnavailablePreview_RefusesEveryWrite(string operationId) + { + var tool = new StubTool(operationId); + var executor = CreateExecutor(new AiToolRegistry([tool]), Substitute.For()); + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, operationId, + Parse("{}"), AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); + response.PendingOperation.Should().BeNull(); + tool.Calls.Should().Be(0); + _dbContext.PendingAgentOperations.Should().OnlyContain(item => item.ConsumedAtUtc != null); + } + + [Fact] + public async Task RoundThree_MissingStoredFingerprint_CannotApprove() + { + var capability = _catalogService.GetCapabilityByChatTool("create_goal")!; + var pending = _pendingOperationStore.Create(_userId, capability, "create_goal", "{}", "Create", + AgentOperationFingerprint.Compute("create_goal", "{}"), AgentExecutionSurface.Chat); + var tools = new AiToolRegistry([new StubTool("create_goal")]); + var revisions = new PendingOperationRevisionService(_pendingOperationStore, + Substitute.For(), new RevisePendingOperationRequestValidator(), tools); + (await revisions.IsCurrentAsync(_userId, _pendingOperationStore.GetExecution(_userId, pending.Id)!, default)) + .Should().BeFalse(); + } + + [Fact] + public void RoundThree_ConfirmedProposals_AreNotStillPending() + { + var capability = _catalogService.GetCapabilityByChatTool("bulk_create_habits")!; + const string args = """{"habits":[{"title":"Novo"}]}"""; + var pending = _pendingOperationStore.Create(_userId, capability, "bulk_create_habits", args, "Create", + AgentOperationFingerprint.Compute("bulk_create_habits", args), AgentExecutionSurface.Chat); + _pendingOperationStore.Confirm(_userId, pending.Id).Should().NotBeNull(); + _pendingOperationStore.GetPendingHabitCreations(_userId).Should().BeEmpty(); + } + private AgentOperationExecutor CreateExecutor(AiToolRegistry toolRegistry, IPendingOperationChangePreviewer? previewer = null) { @@ -522,6 +563,13 @@ private AgentOperationExecutor CreateExecutor(AiToolRegistry toolRegistry, stepUpBridge.GetRequiredConfirmationAsync(Arg.Any(), Arg.Any()) .Returns((AgentConfirmationRequirement?)null); + if (previewer is null) + { + previewer = Substitute.For(); + previewer.PreviewAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => AgentArgumentPreview.Build(call.Arg(), call.Arg(), null, default)); + } + return new AgentOperationExecutor( _catalogService, _policyEvaluator, @@ -529,7 +577,7 @@ private AgentOperationExecutor CreateExecutor(AiToolRegistry toolRegistry, ownership, stepUpBridge, toolRegistry, - previewer ?? Substitute.For(), + previewer, _pendingOperationStore, Substitute.For(), NullLogger.Instance); diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs index 0b561e297..2d9e4c107 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentExecutionAndSanitizerTests.cs @@ -14,6 +14,7 @@ using Orbit.Api.Controllers; using Orbit.Application.Auth.Commands; using Orbit.Application.Auth.Models; +using Orbit.Application.Chat; using Orbit.Application.Chat.Tools; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -420,14 +421,15 @@ public async Task AgentOperationExecutor_ReturnsPendingConfirmation() AgentAuthMethod.Jwt)); response.Operation.Status.Should().Be(AgentOperationStatus.PendingConfirmation); - response.PendingOperation.Should().Be(pending); + response.PendingOperation!.Id.Should().Be(pending.Id); + response.PendingOperation.PreviewFingerprint.Should().NotBeNullOrWhiteSpace(); } [Theory] [InlineData(AgentExecutionSurface.Chat, true, true)] - [InlineData(AgentExecutionSurface.Chat, false, false)] + [InlineData(AgentExecutionSurface.Chat, false, true)] [InlineData(AgentExecutionSurface.Mcp, true, false)] - public async Task AgentOperationExecutor_PreviewsOnlyCapableChatConfirmations( + public async Task AgentOperationExecutor_PreviewsEveryChatConfirmation( AgentExecutionSurface surface, bool includePreview, bool expectedPreview) { var catalog = Substitute.For(); @@ -446,7 +448,8 @@ public async Task AgentOperationExecutor_PreviewsOnlyCapableChatConfirmations( var previewer = Substitute.For(); previewer.PreviewAsync(UserId, operation.Id, Arg.Any(), Arg.Any()) .Returns(new PendingOperationChangePreview( - [new PendingOperationChange(Guid.NewGuid(), "Habit", "emoji", "🔴", "✅", "emoji")], 1)); + [new PendingOperationChange(Guid.Empty, "Habit", "emoji", "🔴", "✅", "emoji")], 1, + [new PendingOperationItem("0", null, "Habit", [], "state")], "fresh")); var executor = CreateExecutor(catalog, policyEvaluator: policy, changePreviewer: previewer); var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( @@ -964,6 +967,15 @@ private static AgentOperationExecutor CreateExecutor( .Returns((string?)null); } + if (changePreviewer is null) + { + changePreviewer = Substitute.For(); + changePreviewer.PreviewAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => AgentArgumentPreview.Build(call.Arg(), call.Arg(), null, default)); + } + var pendingStore = Substitute.For(); + pendingStore.Revise(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + return new AgentOperationExecutor( catalogService, policyEvaluator ?? Substitute.For(), @@ -971,8 +983,8 @@ private static AgentOperationExecutor CreateExecutor( ownershipService, stepUpAuthorizationBridge, toolRegistry ?? new AiToolRegistry([]), - changePreviewer ?? Substitute.For(), - Substitute.For(), + changePreviewer, + pendingStore, unitOfWork ?? Substitute.For(), logger ?? NullLogger.Instance); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs index a1efd5f62..5dbb636cb 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AstraHabitQueryRegressionTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FluentAssertions; +using Microsoft.EntityFrameworkCore.Diagnostics; using NSubstitute; using Orbit.Application.Chat.Tools.Implementations; using Orbit.Domain.Entities; @@ -12,9 +13,49 @@ namespace Orbit.Infrastructure.Tests.Services; public class AstraHabitQueryRegressionTests { + [Fact] + public async Task RoundThree_FilteredMetrics_MaterializesOnlyMatchingHistory() + { + var materialized = new LogMaterializationCounter(); + using var factory = new SqliteOrbitDbContextFactory(materialized); + var db = factory.Context; + var user = User.Create("Test", "query@example.com").Value; + var today = new DateOnly(2026, 10, 19); + var water = Habit.Create(new HabitCreateParams(user.Id, "Beber água", FrequencyUnit.Day, 1, today)).Value; + var reading = Habit.Create(new HabitCreateParams(user.Id, "Ler 10 minutos", FrequencyUnit.Day, 1, today)).Value; + water.Log(today.AddDays(-1)); + reading.Log(today.AddDays(-1)); + db.Users.Add(user); + db.Habits.AddRange(water, reading); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + var dates = Substitute.For(); + dates.GetUserTodayAsync(user.Id, Arg.Any()).Returns(today); + var tool = new QueryHabitsTool(new GenericRepository(db), new GenericRepository(db), dates, + Substitute.For()); + + var result = await tool.ExecuteAsync(JsonDocument.Parse("""{"search":"AGUA","include_metrics":true}""").RootElement, user.Id, default); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Contain("Beber água").And.NotContain("Ler 10 minutos"); + materialized.HabitIds.Should().OnlyContain(id => id == water.Id); + materialized.HabitIds.Should().Contain(water.Id); + } + + private sealed class LogMaterializationCounter : IMaterializationInterceptor + { + public List HabitIds { get; } = []; + public object InitializedInstance(MaterializationInterceptionData materializationData, object entity) + { + if (entity is HabitLog log) + HabitIds.Add(log.HabitId); + return entity; + } + } + [Theory] [InlineData("{\"search\":\"AGUA\"}", "Beber água")] - [InlineData("{\"search\":\"leitura\"}", "Ler 10 minutos")] + [InlineData("{\"search\":\"LER\"}", "Ler 10 minutos")] [InlineData("{\"tag\":\"saude\"}", "Beber água")] public async Task QueryHabits_AccountShape_SearchExecutesOnRelationalProvider(string arguments, string expected) { diff --git a/tests/Orbit.Infrastructure.Tests/Services/BulkCreateAgentOperationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/BulkCreateAgentOperationTests.cs index f52f2a545..0b3db594b 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/BulkCreateAgentOperationTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/BulkCreateAgentOperationTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using NSubstitute; using Orbit.Application.Behaviors; +using Orbit.Application.Chat; using Orbit.Application.Chat.Tools; using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.Habits.Commands; @@ -90,10 +91,13 @@ public BulkCreateAgentOperationTests() var ownership = Substitute.For(); ownership.GetDenialReasonAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns((string?)null); + var registry = new AiToolRegistry([tool]); + var previewer = new PendingOperationChangePreviewer(new GenericRepository(context), + new GenericRepository(context), new GenericRepository(context), dates, registry); _executor = new AgentOperationExecutor( catalog, policy, _audit, ownership, bridge, - new AiToolRegistry([tool]), - Substitute.For(), + registry, + previewer, _pending, work, _logger); } From 42c9b4fb536e10229ac4876fd047a05d7c300dd0 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 21:10:19 -0300 Subject: [PATCH 9/9] fix: preserve Astra previews and normalize habit resolution Fixes thomasluizon/orbit-tickets#1568 --- .../Chat/AgentArgumentPreview.cs | 3 +- .../ProcessUserChatCommand.Accumulator.cs | 2 + .../ProcessUserChatCommand.Resolution.cs | 81 ++++++----- .../Commands/ProcessUserChatCommand.Tools.cs | 9 +- .../Chat/HabitNameMatcher.cs | 4 +- .../Chat/PendingOperationChangePreviewer.cs | 5 +- .../Chat/PendingOperationRevisionService.cs | 3 +- .../Implementations/BulkHabitToolArguments.cs | 2 +- .../Tools/Implementations/QueryHabitsTool.cs | 2 +- .../Habits/Commands/BulkHabitSelection.cs | 4 +- .../Interfaces/IAgentPlatformServices.cs | 2 +- .../Services/AgentOperationExecutor.cs | 2 +- .../Services/PendingAgentOperationStore.cs | 10 +- .../Sections/Static/GlobalRulesSection.cs | 2 +- .../Chat/AgentArgumentPreviewTests.cs | 1 + .../PendingOperationChangePreviewerTests.cs | 39 +++-- .../Chat/QueryHabitsToolTests.cs | 14 ++ ...sUserChatCommandHandlerTests.Resolution.cs | 137 +++++++++++++++++- .../ProcessUserChatCommandHandlerTests.cs | 22 +-- .../Services/AgentChatWriteHoldTests.cs | 15 +- .../PendingAgentOperationStoreTests.cs | 47 ++++++ 21 files changed, 326 insertions(+), 80 deletions(-) diff --git a/src/Orbit.Application/Chat/AgentArgumentPreview.cs b/src/Orbit.Application/Chat/AgentArgumentPreview.cs index c2d8adf15..d54d19095 100644 --- a/src/Orbit.Application/Chat/AgentArgumentPreview.cs +++ b/src/Orbit.Application/Chat/AgentArgumentPreview.cs @@ -46,7 +46,8 @@ public static class AgentArgumentPreview var entityId = target?.EntityId ?? Guid.Empty; var entityName = target?.EntityName ?? ReadName(arguments) ?? string.Empty; var fields = arguments.EnumerateObject() - .OrderBy(property => property.Name, StringComparer.Ordinal) + .OrderBy(property => property.Name != "title") + .ThenBy(property => property.Name, StringComparer.Ordinal) .Select(property => new PendingOperationChange( entityId, entityName, diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs index f7247b7c2..2d15c91f9 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs @@ -24,10 +24,12 @@ internal sealed class ToolExecutionAccumulator public bool IsTaggingIntent { get; init; } public HashSet ExistingDuplicateNames { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet PendingDuplicateNames { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet BlockedReplacementNames { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary AmbiguousHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary InvalidCalls { get; } = new(StringComparer.Ordinal); public HashSet MissingHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet PreviewedCalls { get; } = new(StringComparer.Ordinal); + public HashSet MergedCallIds { get; } = new(StringComparer.Ordinal); public DateTime? AnsweredAtUtc { get; init; } public List CommittedActions { get; } = []; diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs index 7c9c6334e..1833ea53d 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Resolution.cs @@ -10,9 +10,6 @@ namespace Orbit.Application.Chat.Commands; public partial class ProcessUserChatCommandHandler { - private const string PortuguesePendingQuestion = "Você já tem uma prévia para criar"; - private const string EnglishPendingQuestion = "You already have a preview to create"; - private static bool IsExplicitHabitCreate(string message) => Regex.IsMatch(message, @"\b(crie|cria|criar|create|add|adicione)\s+(?:(?:um|uma|o|a|os|as|new|one|novo|nova)\s+)?(?:h[aá]bitos?|habits?|rotina)\b", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); @@ -38,27 +35,20 @@ private static bool RequestsExistingParent(string message, IReadOnlyList private void SeedProposedHabitNames(ProcessUserChatCommand request, ToolExecutionAccumulator results) { - var previousReply = request.History?.LastOrDefault(item => - ChatHistoryMessage.NormalizeRole(item.Role) == ChatHistoryMessage.AssistantRole)?.Content; var proposals = execution.PendingOperationStore.GetPendingHabitCreations(request.UserId); + var normalizedMessage = HabitNameMatcher.Normalize(request.Message); + var asksForReplacement = Regex.IsMatch(normalizedMessage, + @"\b(nova|new|outra|another)\s+(previa|preview)\b|\b(prepare|preparar|make)\s+(?:(?:um|uma|a)\s+)?(?:nova|new|outra|another)(?: one)?$|\b(again|de novo|reprepare)\b", + RegexOptions.None, TimeSpan.FromSeconds(1)); foreach (var name in proposals) { - var discussed = previousReply is not null - && (previousReply.Contains(PortuguesePendingQuestion, StringComparison.Ordinal) - || previousReply.Contains(EnglishPendingQuestion, StringComparison.Ordinal)) - && HabitNameMatcher.Matches(previousReply, name.Title); var named = HabitNameMatcher.Matches(request.Message, name.Title); - var asksForReplacement = Regex.IsMatch(HabitNameMatcher.Normalize(request.Message), - @"\b(nova|new|outra|another)\s+(previa|preview)\b|\b(prepare|preparar|make)\s+(?:(?:um|uma|a)\s+)?(?:nova|new|outra|another)(?: one)?$", - RegexOptions.None, TimeSpan.FromSeconds(1)); - var explicitlyReplaces = asksForReplacement && (named || proposals.Count == 1) - || discussed && named && Regex.IsMatch(request.Message, - @"\b(crie|cria|criar|create|prepare|preparar|nova|new|again|de novo)\b", - RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); - if (explicitlyReplaces) + if (asksForReplacement && (named || proposals.Count == 1)) { - execution.PendingOperationStore.SupersedeHabitCreation(request.UserId, name.Title, name.ParentHabitId); - continue; + if (execution.PendingOperationStore.SupersedeHabitCreation(request.UserId, name.Title, name.ParentHabitId)) + continue; + results.BlockedReplacementNames.Add(name.Title); + results.PendingDuplicateNames.Add(name.Title); } var key = (name.ParentHabitId, HabitNameMatcher.Normalize(name.Title)); results.ProposedHabitNames.Add(key); @@ -185,7 +175,10 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati } var proposed = NewHabitItems(items, arguments, results); if (proposed is null) - return call; + { + results.InvalidCalls[call.Id] = "Every habit must have a non-empty string title."; + return null; + } if (proposed.Count == 0) return null; if (call.Name == "bulk_create_habits") @@ -197,12 +190,14 @@ private static bool ResolveHabitIdentifiers(JsonObject arguments, string operati private static JsonArray? NewHabitItems(JsonArray items, JsonObject arguments, ToolExecutionAccumulator results) { + if (items.Any(item => item is not JsonObject habit || habit["title"] is not JsonValue value + || !value.TryGetValue(out var title) || string.IsNullOrWhiteSpace(title))) + return null; Guid? parentId = Guid.TryParse(arguments["parent_habit_id"]?.ToString(), out var parsed) ? parsed : null; var proposed = new JsonArray(); foreach (var item in items.OfType()) { - if (item["title"] is not JsonValue value || !value.TryGetValue(out var name) || string.IsNullOrWhiteSpace(name)) - return null; + var name = item["title"]!.GetValue(); if (results.IsTaggingIntent) continue; var existing = results.Habits.FirstOrDefault(habit => habit.ParentHabitId == parentId @@ -253,10 +248,14 @@ private async Task CoalesceLogCallsAsync(IReadOnlyList calls, Args = JsonSerializer.SerializeToElement(new { revised_items = entries.Values.ToArray() }) }; foreach (var call in logs.Where(call => call.Id != first.Id)) + { + if (resolvedCalls[call.Id] is not null) + results.MergedCallIds.Add(call.Id); resolvedCalls[call.Id] = null; + } } - private static void CoalesceTagCalls(Dictionary calls) + private static void CoalesceTagCalls(Dictionary calls, ToolExecutionAccumulator results) { var tags = calls.Values.OfType().Where(call => call.Name == "assign_tags" && call.Args.ValueKind == JsonValueKind.Object) .GroupBy(call => @@ -286,7 +285,10 @@ private static void CoalesceTagCalls(Dictionary calls) arguments["habit_ids"] = new JsonArray(ids.Order(StringComparer.Ordinal).Select(id => (JsonNode?)JsonValue.Create(id)).ToArray()); calls[first.Id] = first with { Args = JsonSerializer.SerializeToElement(arguments) }; foreach (var call in group.Skip(1)) + { + results.MergedCallIds.Add(call.Id); calls[call.Id] = null; + } } } @@ -303,17 +305,24 @@ private static void CoalesceTagCalls(Dictionary calls) return null; } - private static ToolCallOutcome ResolvedWithoutWrite(AiToolCall call, ToolExecutionAccumulator results) => new( - new AiToolCallResult(call.Name, call.Id, !results.InvalidCalls.ContainsKey(call.Id), null, null, - results.InvalidCalls.GetValueOrDefault(call.Id), new + private static ToolCallOutcome ResolvedWithoutWrite(AiToolCall call, ToolExecutionAccumulator results, string? language) + { + var merged = results.MergedCallIds.Contains(call.Id); + var error = results.InvalidCalls.GetValueOrDefault(call.Id); + return new ToolCallOutcome(new AiToolCallResult(call.Name, call.Id, error is null, null, null, + error, new { - status = "no_new_preview", + status = merged ? "merged_into_preview" : "no_new_preview", unresolved_names = results.MissingHabitNames.ToArray(), ambiguous_names = results.AmbiguousHabitNames, existing_duplicates = results.ExistingDuplicateNames.ToArray(), pending_duplicates = results.PendingDuplicateNames.ToArray(), - instruction = "No new preview was prepared. Ask about the returned missing, ambiguous or duplicate names. Ignore unsolicited creates for a tag-only request." - }), null, null, null, null); + instruction = merged + ? "This call was merged into the held preview for this intent. Do not repeat the call or prepare another preview. Nothing changes until approval." + : "No new preview was prepared. Ask about the returned missing, ambiguous or duplicate names. Ignore unsolicited creates for a tag-only request." + }), error is null ? null : new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: ToolFailureMessage(language)) + .WithToolResultMessage(call, key: "toolFailure"), null, null, null); + } private static string? AddResolutionQuestion(string? message, ToolExecutionAccumulator results, string? language) { @@ -324,8 +333,11 @@ private static void CoalesceTagCalls(Dictionary calls) if (results.MissingHabitNames.Count > 0) { var names = string.Join(", ", results.MissingHabitNames); - questions.Add(portuguese ? $"Não encontrei estes hábitos: {names}. Qual hábito você quer usar?" - : $"I couldn't find these habits: {names}. Which habit would you like to use?"); + questions.Add(results.MissingHabitNames.Count == 1 + ? portuguese ? $"Não encontrei este hábito: {names}. Qual hábito você quer usar?" + : $"I couldn't find this habit: {names}. Which habit would you like to use?" + : portuguese ? $"Não encontrei estes hábitos: {names}. Quais hábitos você quer usar?" + : $"I couldn't find these habits: {names}. Which habits would you like to use?"); } foreach (var (name, matches) in results.AmbiguousHabitNames) questions.Add(portuguese ? $"Para {name}, encontrei: {string.Join(", ", matches)}. Qual hábito você quer usar?" @@ -334,8 +346,11 @@ private static void CoalesceTagCalls(Dictionary calls) questions.Add(portuguese ? $"O hábito {name} já existe. Você quer usar esse hábito ou criar outro com um nome diferente?" : $"The habit {name} already exists. Would you like to use it or create another with a different name?"); foreach (var name in results.PendingDuplicateNames) - questions.Add(portuguese ? $"{PortuguesePendingQuestion} {name} aguardando. Aprove a prévia ou me diga para preparar uma nova." - : $"{EnglishPendingQuestion} {name} waiting. Approve it, or tell me to prepare a new one."); + questions.Add(results.BlockedReplacementNames.Contains(name) + ? portuguese ? $"A prévia para criar {name} também inclui outros hábitos. Quais itens você quer manter na prévia?" + : $"The preview to create {name} also includes other habits. Which items would you like to keep in the preview?" + : portuguese ? $"Você já tem uma prévia aguardando para criar {name}. Aprove a prévia ou me peça para preparar uma nova." + : $"You already have a preview waiting to create {name}. Approve it, or ask me to prepare a new one."); if (results.PendingOperations.Count > 0) questions.Insert(0, portuguese ? "Revise a prévia antes de aprovar." : "Review the preview before approving."); return string.Join("\n\n", questions); diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs index fe1721298..0f3b7863e 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs @@ -106,7 +106,7 @@ private sealed record ToolLoopResult( if (askCall is not null) orderedCalls.Add(askCall); - var outcomesByCallId = await ExecuteToolCallsAsync(orderedCalls, request, executionResults, cancellationToken); + var outcomesByCallId = await ExecuteToolCallsAsync(orderedCalls, request, executionResults, language, cancellationToken); var toolResults = new List(orderedCalls.Count); var hadToolFailure = false; @@ -178,6 +178,7 @@ private async Task> ExecuteToolCall List orderedCalls, ProcessUserChatCommand request, ToolExecutionAccumulator executionResults, + string? language, CancellationToken cancellationToken) { var readOnlyCalls = orderedCalls @@ -189,7 +190,7 @@ private async Task> ExecuteToolCall var bulkRedirects = BulkToolRepeatGuard.FindRedirects(writeCalls); var resolvedWrites = writeCalls.ToDictionary(call => call.Id, call => ResolveHabitCall(call, executionResults)); await CoalesceLogCallsAsync(writeCalls, resolvedWrites, request.UserId, executionResults, cancellationToken); - CoalesceTagCalls(resolvedWrites); + CoalesceTagCalls(resolvedWrites, executionResults); var readOnlyTasks = readOnlyCalls .Select(call => ExecuteReadOnlyToolCallOnIsolatedScopeAsync(call, request, executionResults, cancellationToken)) @@ -211,7 +212,9 @@ private async Task> ExecuteToolCall if (resolved is null || !executionResults.PreviewedCalls.Add( Orbit.Domain.Common.AgentOperationFingerprint.Compute(resolved.Name, resolved.Args.GetRawText()))) { - outcomesByCallId[call.Id] = ResolvedWithoutWrite(call, executionResults); + if (resolved is not null) + executionResults.MergedCallIds.Add(call.Id); + outcomesByCallId[call.Id] = ResolvedWithoutWrite(call, executionResults, language); continue; } var outcome = executionResults.AnsweredAtUtc is { } claim diff --git a/src/Orbit.Application/Chat/HabitNameMatcher.cs b/src/Orbit.Application/Chat/HabitNameMatcher.cs index 85f764a17..a1a4ab952 100644 --- a/src/Orbit.Application/Chat/HabitNameMatcher.cs +++ b/src/Orbit.Application/Chat/HabitNameMatcher.cs @@ -23,8 +23,8 @@ public static bool Matches(string title, string search) var normalized = Normalize(search); if (normalized.Length == 0) return false; - var candidate = Normalize(title); - return (" " + candidate + " ").Contains(" " + normalized + " ", StringComparison.Ordinal); + var titleWords = Normalize(title).Split(' ', StringSplitOptions.RemoveEmptyEntries); + return normalized.Split(' ').All(word => titleWords.Any(candidate => candidate.StartsWith(word, StringComparison.Ordinal))); } public static Habit? Resolve(IEnumerable habits, string name) diff --git a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs index b39f95df2..504a4e641 100644 --- a/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs +++ b/src/Orbit.Application/Chat/PendingOperationChangePreviewer.cs @@ -533,7 +533,8 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action && habit.TryGetProperty("title", out var title) ? title.ToString() : string.Empty; var fields = habit.ValueKind == JsonValueKind.Object ? habit.EnumerateObject().Where(property => CreateFields.Contains(property.Name)) - .OrderBy(property => property.Name, StringComparer.Ordinal) + .OrderBy(property => property.Name != "title") + .ThenBy(property => property.Name, StringComparer.Ordinal) .Select(property => new PendingOperationChange( Guid.Empty, name, property.Name, null, AgentArgumentPreview.FormatValue(property.Value), "text", property.Value.Clone(), true)).ToList() @@ -553,7 +554,7 @@ [new PendingOperationChange(habit.Id, habit.Title, "delete", null, null, "action RemovesData: false, ValidationErrors: errors.Count > 0 ? errors : null); }).ToList(); return BuildPreview("bulk_create_habits", items.SelectMany(item => item.Fields) - .Where(field => field.Field == "parent_habit_id").ToList(), items); + .Where(field => field.Field == "parent_habit_id").DistinctBy(field => field.EntityId).ToList(), items); } private static PendingOperationChangePreview? BuildPreview( diff --git a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs index 1563f70b7..de825569a 100644 --- a/src/Orbit.Application/Chat/PendingOperationRevisionService.cs +++ b/src/Orbit.Application/Chat/PendingOperationRevisionService.cs @@ -120,7 +120,8 @@ public async Task IsCurrentAsync(Guid userId, PendingAgentOperationExecuti CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(execution.PreviewFingerprint)) - return toolRegistry.GetTool(execution.OperationId)?.IsReadOnly == true; + return execution.Surface != AgentExecutionSurface.Chat + || toolRegistry.GetTool(execution.OperationId)?.IsReadOnly == true; var current = await previewer.PreviewAsync(userId, execution.OperationId, execution.Arguments, cancellationToken); return current?.PreviewFingerprint == execution.PreviewFingerprint; diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs index 5a5dca4a8..c01271e73 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkHabitToolArguments.cs @@ -189,7 +189,7 @@ public static (BulkHabitChanges? Changes, string? Error) ParseChanges(JsonElemen all = new { type = JsonSchemaTypes.Boolean, description = "Select the complete matching set." }, habit_ids = new { type = JsonSchemaTypes.Array, items = new { type = JsonSchemaTypes.String } }, tag = new { type = JsonSchemaTypes.String, description = "Exact tag name, case-insensitive." }, - search = new { type = JsonSchemaTypes.String, description = "Title or description contains text, case-insensitive." }, + search = new { type = JsonSchemaTypes.String, description = "Search titles by word prefixes, ignoring case and accents. Every search word must start a title word; word order does not matter. Descriptions are not searched." }, is_completed = new { type = JsonSchemaTypes.Boolean, description = "Select completed habits when true or active habits when false. Active habits are the default." }, is_general = new { type = JsonSchemaTypes.Boolean }, is_bad_habit = new { type = JsonSchemaTypes.Boolean }, diff --git a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs index 92fba7d21..e4317012c 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs @@ -27,7 +27,7 @@ public class QueryHabitsTool( type = JsonSchemaTypes.Object, properties = new { - search = new { type = JsonSchemaTypes.String, description = "Search habits by title (case-insensitive contains match)" }, + search = new { type = JsonSchemaTypes.String, description = "Search titles by word prefixes, ignoring case and accents. Every search word must start a title word; word order does not matter." }, date = new { type = JsonSchemaTypes.String, description = "Return the canonical schedule for this date (YYYY-MM-DD), including entries already done on that date. Use 'today' for the user's current date. Omit is_completed for the full schedule." }, include_overdue = new { type = JsonSchemaTypes.Boolean, description = "When filtering by date, also include habits overdue before that date. Default: true when date is 'today', false otherwise." }, is_general = new { type = JsonSchemaTypes.Boolean, description = "Filter by general/timeless habits (true) or non-general habits (false). Omit to include both." }, diff --git a/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs b/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs index 53310a8b0..37a42db38 100644 --- a/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs +++ b/src/Orbit.Application/Habits/Commands/BulkHabitSelection.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Orbit.Application.Chat; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -54,8 +55,7 @@ internal static IReadOnlyList Select(IEnumerable habits, BulkHabit .Where(habit => string.IsNullOrWhiteSpace(tag) || habit.Tags.Any(candidate => candidate.Name.Equals(tag, StringComparison.OrdinalIgnoreCase))) .Where(habit => string.IsNullOrWhiteSpace(search) - || habit.Title.Contains(search, StringComparison.OrdinalIgnoreCase) - || habit.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) + || HabitNameMatcher.Matches(habit.Title, search)) .Where(habit => filter.IsGeneral is null || habit.IsGeneral == filter.IsGeneral) .Where(habit => filter.IsBadHabit is null || habit.IsBadHabit == filter.IsBadHabit) .Where(habit => !filter.OneTime || habit.FrequencyUnit is null) diff --git a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs index f5e135768..0a36fe552 100644 --- a/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs +++ b/src/Orbit.Domain/Interfaces/IAgentPlatformServices.cs @@ -49,7 +49,7 @@ PendingAgentOperation Create( PendingAgentOperationExecution? GetExecution(Guid userId, Guid pendingOperationId); bool IsAwaitingConfirmation(Guid userId, Guid pendingOperationId); IReadOnlyList GetPendingHabitCreations(Guid userId); - void SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitId); + bool SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitId); bool Revise(Guid userId, Guid pendingOperationId, string expectedFingerprint, string argumentsJson, string operationFingerprint, string previewFingerprint); bool Cancel(Guid userId, Guid pendingOperationId, string expectedFingerprint); diff --git a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs index d8477343b..6bb38a5ec 100644 --- a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs +++ b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs @@ -243,7 +243,7 @@ await TryAuditAsync( pendingOperationStore.Cancel(execution.Request.UserId, pending.Id, AgentOperationFingerprint.Compute(execution.Operation.Id, execution.Arguments.GetRawText())); return await BuildToolOutcomeResponseAsync(execution, policyDecision, - new ToolResult(false, Error: "preview_unavailable"), cancellationToken); + new ToolResult(false, Error: "stale_preview"), cancellationToken); } var argumentsJson = execution.Arguments.GetRawText(); diff --git a/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs b/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs index 6a73942b8..ee21a020b 100644 --- a/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs +++ b/src/Orbit.Infrastructure/Services/PendingAgentOperationStore.cs @@ -133,13 +133,14 @@ public IReadOnlyList GetPendingHabitCreations(Guid userId) return names; } - public void SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitId) + public bool SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitId) { var checkedAtUtc = TimeProvider.System.GetUtcNow().UtcDateTime; var pending = dbContext.PendingAgentOperations.Where(item => item.UserId == userId && item.ConsumedAtUtc == null && item.ConfirmedAtUtc == null && item.ExpiresAtUtc > checkedAtUtc && (item.OperationId == "create_habit" || item.OperationId == "bulk_create_habits" || item.OperationId == "create_sub_habit")) .ToList(); + var matching = new List(); foreach (var item in pending) { var args = ParseArguments(item.ArgumentsJson); @@ -154,8 +155,13 @@ public void SupersedeHabitCreation(Guid userId, string title, Guid? parentHabitI .Select(habit => habit.TryGetProperty("title", out var name) ? name.ToString() : "") : args.TryGetProperty("title", out var name) ? [name.ToString()] : Array.Empty(); if (titles.Any(name => HabitNameMatcher.Normalize(name) == HabitNameMatcher.Normalize(title))) - Cancel(userId, item.Id, item.OperationFingerprint); + { + if (habits.ValueKind == JsonValueKind.Array && habits.GetArrayLength() > 1) + return false; + matching.Add(item); + } } + return matching.Count > 0 && matching.All(item => Cancel(userId, item.Id, item.OperationFingerprint)); } public bool IsAwaitingConfirmation(Guid userId, Guid pendingOperationId) diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs index 68aa17696..f471e8653 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs @@ -18,7 +18,7 @@ public string Build(PromptContext context) 1. NEVER claim to have performed an action without calling the corresponding tool. If you want to create, update, delete, log, skip, complete, abandon, or link a habit or goal, you MUST call the tool. Never just say "I've done it" in text. 2. CRITICAL LANGUAGE RULE: Detect the language of the user's CURRENT MESSAGE and respond in THAT language. If the user writes in English, respond in English. If they write in Portuguese, respond in Portuguese. The user's message language ALWAYS wins over any other context. 3. Default dates to TODAY when not specified. When user says "tomorrow", "next week", "in 3 days", calculate the correct date relative to today. - 4. DUPLICATE PREVENTION: Before creating a habit or goal, silently check the indexes already provided above. Those indexes are for ACTIVE duplicate checks. A COMPLETED parent may still appear only to preserve the path to an active sub-habit, but COMPLETED entries never block creating a new habit/goal with the same or similar name. For logs, links, tags, and explicitly requested breakdowns, resolve the activity against the existing titles and pass the matching habit ID. The server matches normalized whole words, not synonyms: for a semantic reference such as leitura to Ler 10 minutos, choose the ID from the index or query_habits results rather than passing the alias as a title search. If several habits fit, list them and ask which one. For an explicit create, only an exact title match after normalizing case and accents is a duplicate: Correr may be created alongside Correr 5 km. A duplicate requires a question, never silence. If a tool reports an existing pending preview, name it and ask the person to approve it or request a replacement. Never repeat a create already proposed in this turn. Use parent_habit_id only when the person explicitly asks for children under an existing habit, including a breakdown; show the parent in the preview. A new routine with sub_habits remains a new root. If the requested existing parent is missing, ask which habit to divide. For a tag-only request, tag the named existing habits without proposing any habit creates or asking about activities the person did not mention. NEVER ask the user for permission to check for duplicates - you already have the list. + 4. DUPLICATE PREVENTION: Before creating a habit or goal, silently check the indexes already provided above. Those indexes are for ACTIVE duplicate checks. A COMPLETED parent may still appear only to preserve the path to an active sub-habit, but COMPLETED entries never block creating a new habit/goal with the same or similar name. For logs, links, tags, and explicitly requested breakdowns, resolve the activity against the existing titles and pass the matching habit ID. Title searches and bulk filter.search use the same rule: ignore case and accents, and require every search word to be a prefix of a title word, in any order. Descriptions are not searched. For example, medit matches Meditação, but ler does not match Alergia. Synonyms are not matched: for a semantic reference such as leitura to Ler 10 minutos, choose the ID from the index or query_habits results rather than passing the alias as a title search. If several habits fit, list them and ask which one. For an explicit create, only an exact title match after normalizing case and accents is a duplicate: Correr may be created alongside Correr 5 km. A duplicate requires a question, never silence. If a tool reports an existing pending preview, name it and ask the person to approve it or request a replacement. Never repeat a create already proposed in this turn. Use parent_habit_id only when the person explicitly asks for children under an existing habit, including a breakdown; show the parent in the preview. A new routine with sub_habits remains a new root. If the requested existing parent is missing, ask which habit to divide. For a tag-only request, tag the named existing habits without proposing any habit creates or asking about activities the person did not mention. NEVER ask the user for permission to check for duplicates - you already have the list. 5. ACTION-ORIENTED: When the user's intent AND structure are clear (e.g., "I need to meditate every day" or "rename my marathon goal"), act immediately by calling the appropriate tool. Do NOT ask for permission. If the structure or schedule is ambiguous (unknown days for a weekly habit, unspecified time, checklist-vs-subhabit unclear, multi-step routine with no clear grouping), ask ONE targeted question first, then act. Never ask more than one question at a time. See the Structuring Strategy section for when to use checklist_items vs sub_habits. 6. SUB-HABIT AMBIGUITY: If both a parent and sub-habit match, name both and ask which one the person means. 7. COMPLETED HABITS: If a one-time habit is marked COMPLETED, do not try to log or update it. If the user asks to create it again, create a NEW habit instead of reusing the completed one. diff --git a/tests/Orbit.Application.Tests/Chat/AgentArgumentPreviewTests.cs b/tests/Orbit.Application.Tests/Chat/AgentArgumentPreviewTests.cs index f90fe0fac..a7451aea0 100644 --- a/tests/Orbit.Application.Tests/Chat/AgentArgumentPreviewTests.cs +++ b/tests/Orbit.Application.Tests/Chat/AgentArgumentPreviewTests.cs @@ -53,6 +53,7 @@ public async Task PreviewAsync_HeldCreate_CarriesItemsAndFingerprint() item.EntityId.Should().BeNull(); item.RemovesData.Should().BeFalse(); item.Fields.Should().HaveCount(4); + item.Fields.Select(field => field.Field).Should().Equal("title", "due_time", "frequency_quantity", "frequency_unit"); item.Fields.Should().OnlyContain(field => field.IsEditable); item.Fields.Should().Contain(field => field.Field == "title" && field.NewValue == "Beber agua" && field.OldValue == null); diff --git a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs index 6e3f11b5e..1f8523dbb 100644 --- a/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs +++ b/tests/Orbit.Application.Tests/Chat/PendingOperationChangePreviewerTests.cs @@ -28,11 +28,23 @@ public PendingOperationChangePreviewerTests() => .Returns(new DateOnly(2026, 9, 25)); [Fact] - public async Task RoundThree_BulkDelete_PreservesLiteralSearchSemantics() + public async Task RoundThree_BulkDelete_UsesNormalizedTitleWordPrefixes() { - Setup([CreateHabit("Beber água", null)]); - (await Preview("bulk_delete_habits", """{"filter":{"search":"agua"}}""")).Should().BeNull(); - (await Preview("bulk_delete_habits", """{"filter":{"search":"água"}}""")).Should().NotBeNull(); + var water = CreateHabit("Beber água", null); + var reading = CreateHabit("Ler 10 minutos", null); + var allergy = CreateHabit("Alergia", null); + allergy.Update(new HabitUpdateParams(allergy.Title, "Ler sobre água e meditação", + allergy.FrequencyUnit, allergy.FrequencyQuantity, allergy.Days.ToArray(), allergy.IsBadHabit, allergy.DueDate)) + .IsSuccess.Should().BeTrue(); + Setup([water, reading, allergy]); + foreach (var search in new[] { "agua", "ÁGUA", "agu beb" }) + { + var preview = await Preview("bulk_delete_habits", JsonSerializer.Serialize(new { filter = new { search } })); + preview!.Items.Should().ContainSingle().Which.EntityId.Should().Be(water.Id); + } + var read = await Preview("bulk_delete_habits", """{"filter":{"search":"ler"}}"""); + read!.Items.Should().ContainSingle().Which.EntityId.Should().Be(reading.Id); + (await Preview("bulk_delete_habits", """{"filter":{"search":"medit"}}""")).Should().BeNull(); } [Fact] @@ -45,25 +57,30 @@ public async Task RoundThree_UnchangedUpdateFields_JsonOrderDoesNotInvalidateApp } [Fact] - public async Task RoundThree_CreateChildren_ShowsParentInEveryItem() + public async Task RoundThree_CreateChildren_ShowsParentInEveryItemAndOnceInChanges() { var parent = CreateHabit("Academia", null); _habits.FindAsync(Arg.Any>>(), Arg.Any()).Returns(new[] { parent }); var preview = await Preview("bulk_create_habits", JsonSerializer.Serialize(new { parent_habit_id = parent.Id, - habits = new[] { new { title = "Treino" } } + habits = new[] { new { title = "Aquecimento" }, new { title = "Treino" }, new { title = "Alongamento" } } })); - preview!.Items!.Single().Fields.Should().Contain(field => field.Field == "parent_habit_id" - && field.NewValue == "Academia" && !field.IsEditable); - preview.Changes.Should().Contain(field => field.Field == "parent_habit_id" && field.NewValue == "Academia"); + preview!.Items.Should().HaveCount(3).And.OnlyContain(item => item.Fields.Any(field => field.Field == "parent_habit_id" + && field.NewValue == "Academia" && !field.IsEditable)); + preview.Changes.Should().ContainSingle().Which.Should().Match( + field => field.Field == "parent_habit_id" && field.NewValue == "Academia"); } [Theory] [InlineData("Alergia", "ler", false)] [InlineData("Ler 10 minutos", "leitura", false)] [InlineData("Beber água", "AGUA", true)] - public void RoundThree_NameMatching_UsesNormalizedWholeWords(string title, string search, bool matches) => + [InlineData("Meditação", "medit", true)] + [InlineData("Meditação diária", "DIÁ MED", true)] + [InlineData("Meditação diária", "med falt", false)] + [InlineData("Meditação", "!!!", false)] + public void RoundThree_NameMatching_UsesNormalizedWordPrefixes(string title, string search, bool matches) => HabitNameMatcher.Matches(title, search).Should().Be(matches); [Fact] @@ -285,7 +302,7 @@ public async Task PreviewAsync_Create_ValidBatchPreservesPreviewAndFingerprint() item.ValidationErrors.Should().BeNull(); preview.Changes.Should().BeEmpty(); preview.ChangeTargetCount.Should().Be(1); - item.Fields.Select(field => field.Field).Should().Equal("frequency_quantity", "frequency_unit", "title"); + item.Fields.Select(field => field.Field).Should().Equal("title", "frequency_quantity", "frequency_unit"); var legacyItemJson = JsonSerializer.SerializeToElement(item); legacyItemJson.EnumerateObject().Select(property => property.Name).Should().Equal( "ItemId", "EntityId", "EntityName", "Fields", "StateFingerprint", "RemovesData"); diff --git a/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs b/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs index 31d676c8c..c2bcbabbb 100644 --- a/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs @@ -114,6 +114,20 @@ public async Task Search_MatchesTitleCaseInsensitive() result.EntityName.Should().NotContain("Read books"); } + [Theory] + [InlineData("medit")] + [InlineData("DIÁ MED")] + public async Task RoundFour_Search_MatchesNormalizedTitleWordPrefixes(string search) + { + SetupHabits(CreateHabit("Meditação diária", FrequencyUnit.Day, 1, dueDate: Today), + CreateHabit("Premeditação", FrequencyUnit.Day, 1, dueDate: Today)); + + var result = await Execute(JsonSerializer.Serialize(new { search })); + + result.Success.Should().BeTrue(); + result.EntityName.Should().Contain("Meditação diária").And.NotContain("Premeditação"); + } + [Fact] public async Task Search_MatchesChildReturnsParent() { diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs index 9ee1ce2bf..087cb29b6 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.Resolution.cs @@ -98,7 +98,12 @@ public async Task Replay_HealthTag_DoesNotRecreateExistingHabits() result.Value.PendingOperations.Should().ContainSingle(); held.Should().ContainSingle().Which.OperationId.Should().Be("assign_tags"); held[0].Arguments.GetProperty("habit_id").GetString().Should().Be(habits[2].Id.ToString()); - result.Value.AiMessage.Should().NotContain("Meditação").And.NotContain("Água"); + held.Should().OnlyContain(operation => operation.OperationId != "bulk_create_habits" + && operation.OperationId != "create_habit"); + await _aiIntentService.Received().ContinueWithToolResultsAsync(Arg.Any(), + Arg.Is>(values => values.Any(value => value.Name == "bulk_create_habits" + && JsonSerializer.SerializeToElement(value.Payload).GetProperty("status").GetString() == "no_new_preview")), + Arg.Any?>(), Arg.Any()); } [Fact] @@ -318,6 +323,7 @@ public async Task RoundThree_PendingDuplicate_AsksAndNextExplicitRequestCanRepla var handler = CreateHandler(bulk); var held = HoldReplayWrites(bulk); _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation("Novo")]); + _pendingOperationStore.SupersedeHabitCreation(UserId, "Novo", null).Returns(true); _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"NOVO"}]}""")); ReplyToReplay("Revise a prévia."); @@ -397,6 +403,135 @@ await _aiIntentService.Received().ContinueWithToolResultsAsync(Arg.Any>(values => values.Where(value => value.Payload != null) .All(value => !JsonSerializer.SerializeToElement(value.Payload).EnumerateObject().Any(property => property.Name == "items"))), Arg.Any?>(), Arg.Any()); + var returned = _aiIntentService.ReceivedCalls().Single(call => call.GetMethodInfo().Name == "ContinueWithToolResultsAsync") + .GetArguments()[1].Should().BeAssignableTo>().Subject; + returned.Count(value => JsonSerializer.SerializeToElement(value.Payload).GetProperty("status").GetString() == "awaiting_confirmation") + .Should().Be(1); + var merged = returned.Where(value => JsonSerializer.SerializeToElement(value.Payload).GetProperty("status").GetString() == "merged_into_preview").ToArray(); + merged.Should().HaveCount(5).And.OnlyContain(value => value.Success && value.Error == null); + merged.Should().OnlyContain(value => JsonSerializer.SerializeToElement(value.Payload).GetProperty("instruction").GetString()! + .Contains("Do not repeat", StringComparison.Ordinal)); + } + + [Fact] + public async Task RoundFour_MalformedCreateAfterDuplicate_DoesNotReachExecutor() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Academia"},{"title":42}]}""")); + ReplyToReplay("Tente novamente."); + + var result = await handler.Handle(new(UserId, "Crie Academia e outro hábito."), default); + + held.Should().BeEmpty(); + result.Value.PendingOperations.Should().BeEmpty(); + result.Value.Actions.Should().Contain(action => action.Status == ActionStatus.Failed + && action.MessageKey == "chat.toolResult.toolFailure" && action.Error == PortugueseToolFailureMessage); + await _aiIntentService.Received().ContinueWithToolResultsAsync(Arg.Any(), + Arg.Is>(values => values.Any(value => !value.Success + && value.Error != null && value.Error.Contains("title", StringComparison.Ordinal))), + Arg.Any?>(), Arg.Any()); + } + + [Theory] + [InlineData("pt-BR", "Você já tem uma prévia aguardando para criar Novo. Aprove a prévia ou me peça para preparar uma nova.")] + [InlineData("en", "You already have a preview waiting to create Novo. Approve it, or ask me to prepare a new one.")] + public async Task RoundFour_PendingDuplicate_UsesRequestedCopy(string language, string expected) + { + SetupResolutionAccount(); + var user = User.Create("Test", "copy@example.com").Value; + user.SetLanguage(language); + SetupUserAndPayGate(user); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + HoldReplayWrites(bulk); + _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation("Novo")]); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Novo"}]}""")); + ReplyToReplay("Review."); + + var result = await handler.Handle(new(UserId, "Crie Novo."), default); + + result.Value.AiMessage.Should().Be(expected); + result.Value.PendingOperations.Should().BeEmpty(); + } + + [Fact] + public async Task RoundFour_Replacement_UsesStoredProposalWithUnrelatedReplyCopy() + { + SetupResolutionAccount(); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([new PendingHabitCreation("Novo")]); + _pendingOperationStore.SupersedeHabitCreation(UserId, "Novo", null).Returns(true); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Novo"}]}""")); + ReplyToReplay("Revise a prévia."); + + var result = await handler.Handle(new(UserId, "Crie Novo de novo.", + History: [new ChatHistoryMessage("assistant", "Há uma proposta aguardando sua aprovação.")]), default); + + _pendingOperationStore.Received().SupersedeHabitCreation(UserId, "Novo", null); + held.Should().ContainSingle(); + result.Value.PendingOperations.Should().ContainSingle(); + } + + [Theory] + [InlineData("pt-BR", "A prévia para criar Treino também inclui outros hábitos. Quais itens você quer manter na prévia?")] + [InlineData("en", "The preview to create Treino also includes other habits. Which items would you like to keep in the preview?")] + public async Task RoundFour_MultiItemReplacement_AsksBeforeSupersedingBatch(string language, string expected) + { + SetupResolutionAccount(); + var user = User.Create("Test", "copy@example.com").Value; + user.SetLanguage(language); + SetupUserAndPayGate(user); + var bulk = new BulkCreateHabitsTool(_mediator); + var handler = CreateHandler(bulk); + var held = HoldReplayWrites(bulk); + _pendingOperationStore.GetPendingHabitCreations(UserId).Returns([ + new PendingHabitCreation("Aquecimento"), new PendingHabitCreation("Treino"), new PendingHabitCreation("Alongamento")]); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("bulk_create_habits", """{"habits":[{"title":"Treino"}]}""")); + ReplyToReplay("Review."); + + var result = await handler.Handle(new(UserId, "Prepare uma nova prévia para Treino."), default); + + held.Should().BeEmpty(); + result.Value.PendingOperations.Should().BeEmpty(); + result.Value.AiMessage.Should().Be(expected); + } + + [Theory] + [InlineData("pt-BR", false, "Não encontrei este hábito: Meditação. Qual hábito você quer usar?")] + [InlineData("pt-BR", true, "Não encontrei estes hábitos: Meditação, Yoga. Quais hábitos você quer usar?")] + [InlineData("en", false, "I couldn't find this habit: Meditação. Which habit would you like to use?")] + [InlineData("en", true, "I couldn't find these habits: Meditação, Yoga. Which habits would you like to use?")] + public async Task RoundFour_MissingNames_UsesMatchingNumberInQuestion(string language, bool multiple, string expected) + { + SetupResolutionAccount(); + var user = User.Create("Test", "copy@example.com").Value; + user.SetLanguage(language); + SetupUserAndPayGate(user); + var tags = new AssignTagsTool(_habitRepo, _tagRepo, _unitOfWork); + var handler = CreateHandler(tags); + var held = HoldReplayWrites(tags); + _aiIntentService.SendWithToolsAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(ReplayCall("assign_tags", JsonSerializer.Serialize(new + { + habit_ids = multiple ? new[] { "Meditação", "Yoga" } : new[] { "Meditação" }, + tag_names = new[] { "Saúde" } + }))); + ReplyToReplay("Choose."); + + var result = await handler.Handle(new(UserId, "Atribua Saúde aos hábitos."), default); + + result.Value.AiMessage.Should().Be(expected); + result.Value.PendingOperations.Should().BeEmpty(); + held.Should().BeEmpty(); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index 1d81953b3..ee11f2b0a 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -2347,7 +2347,7 @@ public async Task Handle_ToolCallWithSuccess_ReturnsActionResult() var handler = CreateHandler(mockTool); - var toolCallArgs = JsonDocument.Parse("{}").RootElement; + var toolCallArgs = JsonDocument.Parse("""{"title":"Parent"}""").RootElement; var toolCall = new AiToolCall("create_habit", "call_1", toolCallArgs); var aiResponseWithTool = new AiResponse { @@ -2541,7 +2541,7 @@ public async Task Handle_TwoToolCalls_StreamsStepsBeforeNextToolAndFinal() { ToolCalls = [ - new AiToolCall("create_habit", "habit", ParseArguments("{}")), + new AiToolCall("create_habit", "habit", ParseArguments("""{"title":"Parent"}""")), new AiToolCall("create_goal", "goal", ParseArguments("{}")) ], ConversationContext = TestConversationContext @@ -2650,7 +2650,7 @@ await _operationExecutor.DidNotReceive().ExecuteAsync( public async Task Handle_ToolFailure_EmitsStartedStepButNoFollowUps() { SetupUserAndPayGate(); - SetupAiResponse(ToolResponse("create_habit", "one", "{}")); + SetupAiResponse(ToolResponse("create_habit", "one", """{"title":"Parent"}""")); var events = new List(); var result = await CreateHandler(CreateFailingCreateHabitTool()).Handle(new ProcessUserChatCommand( @@ -2675,7 +2675,7 @@ public async Task Handle_LaterToolFailure_KeepsStartedSteps() _aiIntentService.ContinueWithToolResultsAsync( Arg.Any(), Arg.Any>(), Arg.Any?>(), Arg.Any()) - .Returns(Result.Success(ToolResponse("create_habit", "two", "{}"))); + .Returns(Result.Success(ToolResponse("create_habit", "two", """{"title":"Parent"}"""))); var events = new List(); var result = await CreateHandler(readTool, CreateFailingCreateHabitTool()).Handle( @@ -4018,7 +4018,7 @@ public async Task Handle_ToolThrowsException_ReturnsFailedAction() var handler = CreateHandler(throwingTool); - var toolCallArgs = JsonDocument.Parse("{}").RootElement; + var toolCallArgs = JsonDocument.Parse("""{"title":"Parent"}""").RootElement; var aiResponseWithTool = new AiResponse { ToolCalls = [new AiToolCall("create_habit", "call_1", toolCallArgs)], @@ -4285,12 +4285,12 @@ public async Task Handle_ToolCallOrdering_CreateHabitRunsBeforeSubHabit() var handler = CreateHandler(createTool, subTool); - var toolCallArgs = JsonDocument.Parse("{}").RootElement; + var toolCallArgs = JsonDocument.Parse("""{"title":"Parent"}""").RootElement; var aiResponseWithTools = new AiResponse { ToolCalls = [ - new AiToolCall("create_sub_habit", "call_2", toolCallArgs), + new AiToolCall("create_sub_habit", "call_2", ParseArguments("""{"title":"Child"}""")), new AiToolCall("create_habit", "call_1", toolCallArgs) ], ConversationContext = TestConversationContext @@ -4320,13 +4320,13 @@ public async Task Handle_ToolCallOrdering_RespectsToolOrderProperty() var handler = CreateHandler(createTool, subTool, assignTool); - var toolCallArgs = JsonDocument.Parse("{}").RootElement; + var toolCallArgs = JsonDocument.Parse("""{"title":"Parent"}""").RootElement; var aiResponseWithTools = new AiResponse { ToolCalls = [ new AiToolCall("assign_tags", "call_3", toolCallArgs), - new AiToolCall("create_sub_habit", "call_2", toolCallArgs), + new AiToolCall("create_sub_habit", "call_2", ParseArguments("""{"title":"Child"}""")), new AiToolCall("create_habit", "call_1", toolCallArgs) ], ConversationContext = TestConversationContext @@ -4337,7 +4337,7 @@ public async Task Handle_ToolCallOrdering_RespectsToolOrderProperty() Arg.Any(), Arg.Any>(), Arg.Any?>(), Arg.Any()) .Returns(Result.Success(new AiResponse { TextMessage = "Done!", ToolCalls = null })); - var command = new ProcessUserChatCommand(UserId, "Create parent, child, and tag"); + var command = new ProcessUserChatCommand(UserId, "Create a habit with a child, and assign a tag"); await handler.Handle(command, CancellationToken.None); executionOrder.Should().Equal("create_habit", "create_sub_habit", "assign_tags"); @@ -4545,7 +4545,7 @@ public async Task Handle_ReadOnlyToolsDispatchConcurrently_WhileWriteToolsRunSeq var handler = CreateHandler(firstReadOnly, secondReadOnly, firstWrite, secondWrite); - var toolCallArgs = JsonDocument.Parse("{}").RootElement; + var toolCallArgs = JsonDocument.Parse("""{"title":"Parent"}""").RootElement; SetupAiResponse(new AiResponse { ToolCalls = diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs index 8e392c62c..5ea7f9d6c 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentChatWriteHoldTests.cs @@ -296,11 +296,13 @@ public async Task ExecuteAsync_WrongConfirmationToken_WritesNothing() } [Theory] - [InlineData(true, true)] - [InlineData(true, false)] - [InlineData(false, true)] + [InlineData(true, true, AgentExecutionSurface.Chat)] + [InlineData(true, false, AgentExecutionSurface.Chat)] + [InlineData(false, true, AgentExecutionSurface.Chat)] + [InlineData(false, false, AgentExecutionSurface.Mcp)] + [InlineData(false, true, AgentExecutionSurface.Mcp)] public async Task ExecutePendingOperation_InitialPreview_BindsApprovalToTargetSet( - bool includePreview, bool addHabit) + bool includePreview, bool addHabit, AgentExecutionSurface surface) { var today = new DateOnly(2026, 9, 25); _dbContext.Habits.Add( @@ -319,7 +321,7 @@ public async Task ExecutePendingOperation_InitialPreview_BindsApprovalToTargetSe var held = await executor.ExecuteAsync(new AgentExecuteOperationRequest( _userId, tool.Name, Parse("""{"filter":{"all":true}}"""), - AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: includePreview)); + surface, AgentAuthMethod.Jwt, IncludeChangePreview: includePreview)); held.Operation.Status.Should().Be(AgentOperationStatus.PendingConfirmation); var pending = held.PendingOperation!; @@ -338,7 +340,7 @@ public async Task ExecutePendingOperation_InitialPreview_BindsApprovalToTargetSe _dbContext.ChangeTracker.Clear(); var execution = _pendingOperationStore.GetExecution(_userId, pending.Id)!; - var shouldExecute = !addHabit; + var shouldExecute = surface == AgentExecutionSurface.Mcp || !addHabit; (await revisions.IsCurrentAsync(_userId, execution, CancellationToken.None)) .Should().Be(shouldExecute); execution.PreviewFingerprint.Should().Be(pending.PreviewFingerprint); @@ -525,6 +527,7 @@ public async Task RoundThree_UnavailablePreview_RefusesEveryWrite(string operati var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest(_userId, operationId, Parse("{}"), AgentExecutionSurface.Chat, AgentAuthMethod.Jwt, IncludeChangePreview: true)); response.PendingOperation.Should().BeNull(); + response.Operation.PolicyReason.Should().Be("stale_preview"); tool.Calls.Should().Be(0); _dbContext.PendingAgentOperations.Should().OnlyContain(item => item.ConsumedAtUtc != null); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/PendingAgentOperationStoreTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PendingAgentOperationStoreTests.cs index 7e4d18298..bd5843a6d 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PendingAgentOperationStoreTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PendingAgentOperationStoreTests.cs @@ -387,6 +387,53 @@ public void Cancel_RejectsAnyLaterConfirmationOrExecution() _store.GetExecution(_userId, pending.Id).Should().BeNull(); } + [Fact] + public void RoundFour_SupersedeOneChild_PreservesTheMultiItemOperation() + { + var parentId = Guid.NewGuid(); + var capability = _catalogService.GetCapabilityByChatTool("bulk_create_habits")! + with + { ConfirmationRequirement = AgentConfirmationRequirement.FreshConfirmation }; + var json = JsonSerializer.Serialize(new + { + parent_habit_id = parentId, + habits = new[] { new { title = "Aquecimento" }, new { title = "Treino" }, new { title = "Alongamento" } } + }); + var pending = _store.Create(_userId, capability, "bulk_create_habits", json, "Create children", + AgentOperationFingerprint.Compute("bulk_create_habits", json), AgentExecutionSurface.Chat); + + _store.SupersedeHabitCreation(_userId, "TREINO", parentId).Should().BeFalse(); + + _dbContext.ChangeTracker.Clear(); + var remaining = _store.GetExecution(_userId, pending.Id); + remaining.Should().NotBeNull(); + remaining!.Arguments.GetProperty("habits").EnumerateArray().Select(item => item.GetProperty("title").GetString()) + .Should().Equal("Aquecimento", "Treino", "Alongamento"); + _store.GetPendingHabitCreations(_userId).Should().HaveCount(3); + _store.Confirm(_userId, pending.Id).Should().NotBeNull(); + } + + [Theory] + [InlineData("create_habit", "{\"title\":\"Meditação\"}")] + [InlineData("bulk_create_habits", "{\"habits\":[{\"title\":\"Meditação\"}]}")] + public void RoundFour_SupersedeSingleItem_CancelsOnlyMatchingProposal(string operationId, string json) + { + var capability = _catalogService.GetCapabilityByChatTool(operationId)! + with + { ConfirmationRequirement = AgentConfirmationRequirement.FreshConfirmation }; + var pending = _store.Create(_userId, capability, operationId, json, "Create", + AgentOperationFingerprint.Compute(operationId, json), AgentExecutionSurface.Chat); + + _store.SupersedeHabitCreation(Guid.NewGuid(), "MEDITACAO", null).Should().BeFalse(); + _store.SupersedeHabitCreation(_userId, "MEDITACAO", Guid.NewGuid()).Should().BeFalse(); + _store.IsAwaitingConfirmation(_userId, pending.Id).Should().BeTrue(); + _store.SupersedeHabitCreation(_userId, "MEDITACAO", null).Should().BeTrue(); + + _dbContext.ChangeTracker.Clear(); + _store.GetExecution(_userId, pending.Id).Should().BeNull(); + _store.Confirm(_userId, pending.Id).Should().BeNull(); + } + [Fact] public void Revise_RejectsFingerprintThatDoesNotMatchPayload() {