diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 49677ce0c..8f22067f2 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -39,7 +39,8 @@ public record ChatResponse( IReadOnlyList? PendingOperations = null, IReadOnlyList? PolicyDenials = null, string? CorrelationId = null, - IReadOnlyList? RelatedSurfaces = null); + IReadOnlyList? RelatedSurfaces = null, + HabitListCard? HabitList = null); public record ActionResult( string Type, @@ -134,6 +135,14 @@ public async Task> Handle( var aiMessage = StripJsonWrapper(aiResponse.TextMessage); + HabitListCard? habitList = null; + if (HabitListCardBuilder.TryExtractScope(aiMessage, out var habitListScope, out var strippedMessage)) + { + aiMessage = strippedMessage; + if (request.ClientContext?.SupportsHabitListCard == true) + habitList = HabitListCardBuilder.Build(context.ActiveHabits, context.UserToday, habitListScope); + } + RunBackgroundPostResponseWork( request.UserId, request.Message, @@ -155,7 +164,8 @@ public async Task> Handle( executionResults.PendingOperations, executionResults.PolicyDenials, request.CorrelationId, - executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null)); + executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null, + habitList)); } private async Task> LoadChatContextAsync( @@ -261,6 +271,9 @@ private async Task> RequestInitialAiResponseAsync( ai.PromptBuilder.BuildDynamic(promptRequest), ai.CatalogService.BuildDynamicSupplement(agentSnapshot)); + if (request.ClientContext?.SupportsHabitListCard == true) + systemPrompt = string.Join(Environment.NewLine, systemPrompt, HabitListCardBuilder.PromptInstruction); + var tools = ai.ToolRegistry.GetAll() .OrderBy(t => t.Name, StringComparer.Ordinal) .ToList(); diff --git a/src/Orbit.Application/Chat/HabitListCard.cs b/src/Orbit.Application/Chat/HabitListCard.cs new file mode 100644 index 000000000..3a29956a7 --- /dev/null +++ b/src/Orbit.Application/Chat/HabitListCard.cs @@ -0,0 +1,120 @@ +using System.Text.RegularExpressions; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Chat; + +public record HabitListCard(string Scope, IReadOnlyList Items); + +public record HabitListCardItem( + string Id, + string Title, + string? Emoji, + int Depth, + bool IsBadHabit, + string Status); + +public static partial class HabitListCardBuilder +{ + public const string ScopeToday = "today"; + public const string ScopeAll = "all"; + + public const string StatusToday = "today"; + public const string StatusOverdue = "overdue"; + public const string StatusGeneral = "general"; + public const string StatusNone = "none"; + + public const string PromptInstruction = """ + ## Habit list rendering (this client) + This app can display the user's habits as a live, interactive card. When the user asks to see or list their habits, or what is due, scheduled, left, or overdue (for example "what are my habits today", "show my habits", "list everything", "o que tenho pra hoje"), do NOT write the habits out as text and do NOT enumerate them. This rule overrides any earlier instruction to list habits from the index. + Instead reply with a brief one-line intro and then, on its own final line, exactly ONE directive token: + - [[orbit:habits:today]] - the user's habits due today plus anything overdue. + - [[orbit:habits:all]] - every active habit. + The app replaces the directive with the rendered habit list, so never list the habits yourself when you emit a directive. Emit at most one directive, always as the last thing in your reply. For every other kind of question, answer normally and do not emit a directive. + """; + + public static bool TryExtractScope(string? message, out string scope, out string stripped) + { + scope = ScopeAll; + stripped = message ?? string.Empty; + if (string.IsNullOrEmpty(message)) + return false; + + var match = DirectiveRegex().Match(message); + if (!match.Success) + return false; + + scope = match.Groups[1].Value.Equals(ScopeToday, StringComparison.OrdinalIgnoreCase) + ? ScopeToday + : ScopeAll; + stripped = DirectiveRegex().Replace(message, string.Empty).Trim(); + return true; + } + + public static HabitListCard Build(IReadOnlyList activeHabits, DateOnly today, string scope) + { + var includedIds = ResolveIncludedIds(activeHabits, today, scope); + var items = new List(); + AppendLevel(activeHabits, parentId: null, depth: 0, today, includedIds, items); + return new HabitListCard(scope, items); + } + + private static HashSet ResolveIncludedIds(IReadOnlyList activeHabits, DateOnly today, string scope) + { + if (!scope.Equals(ScopeToday, StringComparison.OrdinalIgnoreCase)) + return activeHabits.Select(habit => habit.Id).ToHashSet(); + + var byId = activeHabits.ToDictionary(habit => habit.Id); + var included = new HashSet(); + foreach (var habit in activeHabits.Where(habit => !habit.IsGeneral && habit.DueDate <= today)) + { + var current = habit; + while (included.Add(current.Id) && + current.ParentHabitId is Guid parentId && + byId.TryGetValue(parentId, out var parent)) + { + current = parent; + } + } + + return included; + } + + private static void AppendLevel( + IReadOnlyList activeHabits, + Guid? parentId, + int depth, + DateOnly today, + HashSet includedIds, + List items) + { + var children = activeHabits + .Where(habit => habit.ParentHabitId == parentId && includedIds.Contains(habit.Id)) + .OrderBy(habit => habit.Position); + + foreach (var habit in children) + { + items.Add(new HabitListCardItem( + habit.Id.ToString(), + habit.Title, + string.IsNullOrWhiteSpace(habit.Emoji) ? null : habit.Emoji, + depth, + habit.IsBadHabit, + ResolveStatus(habit, today))); + AppendLevel(activeHabits, habit.Id, depth + 1, today, includedIds, items); + } + } + + private static string ResolveStatus(Habit habit, DateOnly today) + { + if (habit.IsGeneral) + return StatusGeneral; + if (habit.DueDate < today) + return StatusOverdue; + if (habit.DueDate == today) + return StatusToday; + return StatusNone; + } + + [GeneratedRegex(@"\[\[orbit:habits:(today|all)\]\]", RegexOptions.IgnoreCase)] + private static partial Regex DirectiveRegex(); +} diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index 8ca7c2b86..9ab0edfe3 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -190,7 +190,8 @@ public record AgentClientContext( string? Locale = null, string? TimeFormat = null, string? CurrentAppArea = null, - bool? ShowGeneralOnToday = null); + bool? ShowGeneralOnToday = null, + bool? SupportsHabitListCard = null); public record AgentContextSnapshot( string Plan, diff --git a/tests/Orbit.Application.Tests/Chat/HabitListCardBuilderTests.cs b/tests/Orbit.Application.Tests/Chat/HabitListCardBuilderTests.cs new file mode 100644 index 000000000..f72d2a7d5 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/HabitListCardBuilderTests.cs @@ -0,0 +1,145 @@ +using FluentAssertions; +using Orbit.Application.Chat; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Tests.Chat; + +public class HabitListCardBuilderTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow); + + private static Habit CreateHabit( + string title, + DateOnly dueDate, + int position = 0, + bool isGeneral = false, + bool isBadHabit = false, + Guid? parentId = null, + string? emoji = null) + { + var habit = Habit.Create(new HabitCreateParams( + UserId, title, + isGeneral ? null : FrequencyUnit.Day, + isGeneral ? null : 1, + DueDate: dueDate, + IsGeneral: isGeneral, + IsBadHabit: isBadHabit, + ParentHabitId: parentId, + Emoji: emoji)).Value; + habit.SetPosition(position); + return habit; + } + + [Fact] + public void TryExtractScope_NoDirective_ReturnsFalse() + { + var found = HabitListCardBuilder.TryExtractScope("Here are your habits.", out _, out var stripped); + + found.Should().BeFalse(); + stripped.Should().Be("Here are your habits."); + } + + [Fact] + public void TryExtractScope_TodayDirective_ReturnsScopeAndStripsToken() + { + var found = HabitListCardBuilder.TryExtractScope( + "Here are your habits for today:\n[[orbit:habits:today]]", out var scope, out var stripped); + + found.Should().BeTrue(); + scope.Should().Be(HabitListCardBuilder.ScopeToday); + stripped.Should().Be("Here are your habits for today:"); + } + + [Fact] + public void TryExtractScope_AllDirective_ReturnsScope() + { + var found = HabitListCardBuilder.TryExtractScope("All of them:\n[[ORBIT:HABITS:ALL]]", out var scope, out _); + + found.Should().BeTrue(); + scope.Should().Be(HabitListCardBuilder.ScopeAll); + } + + [Fact] + public void Build_TodayScope_IncludesDueTodayAndOverdue_ExcludesFutureAndGeneral() + { + var dueToday = CreateHabit("Meditate", Today, position: 0); + var overdue = CreateHabit("Floss", Today.AddDays(-2), position: 1); + var future = CreateHabit("Taxes", Today.AddDays(5), position: 2); + var general = CreateHabit("Read", Today, position: 3, isGeneral: true); + + var card = HabitListCardBuilder.Build([dueToday, overdue, future, general], Today, HabitListCardBuilder.ScopeToday); + + var titles = card.Items.Select(item => item.Title).ToList(); + titles.Should().Contain("Meditate"); + titles.Should().Contain("Floss"); + titles.Should().NotContain("Taxes"); + titles.Should().NotContain("Read"); + } + + [Fact] + public void Build_TodayScope_AssignsStatuses() + { + var dueToday = CreateHabit("Meditate", Today); + var overdue = CreateHabit("Floss", Today.AddDays(-2), position: 1); + + var card = HabitListCardBuilder.Build([dueToday, overdue], Today, HabitListCardBuilder.ScopeToday); + + card.Items.Single(item => item.Title == "Meditate").Status.Should().Be(HabitListCardBuilder.StatusToday); + card.Items.Single(item => item.Title == "Floss").Status.Should().Be(HabitListCardBuilder.StatusOverdue); + } + + [Fact] + public void Build_TodayScope_IncludesAncestorOfDueChild_WithDepth() + { + var parent = CreateHabit("Before Bed", Today.AddDays(10), position: 0); + var dueChild = CreateHabit("Brush teeth", Today, position: 0, parentId: parent.Id); + + var card = HabitListCardBuilder.Build([parent, dueChild], Today, HabitListCardBuilder.ScopeToday); + + var parentItem = card.Items.Single(item => item.Title == "Before Bed"); + var childItem = card.Items.Single(item => item.Title == "Brush teeth"); + parentItem.Depth.Should().Be(0); + childItem.Depth.Should().Be(1); + card.Items.Should().HaveCount(2); + } + + [Fact] + public void Build_AllScope_IncludesEveryActiveHabit() + { + var dueToday = CreateHabit("Meditate", Today, position: 0); + var future = CreateHabit("Taxes", Today.AddDays(5), position: 1); + var general = CreateHabit("Read", Today, position: 2, isGeneral: true); + + var card = HabitListCardBuilder.Build([dueToday, future, general], Today, HabitListCardBuilder.ScopeAll); + + card.Items.Should().HaveCount(3); + card.Items.Single(item => item.Title == "Read").Status.Should().Be(HabitListCardBuilder.StatusGeneral); + card.Items.Single(item => item.Title == "Taxes").Status.Should().Be(HabitListCardBuilder.StatusNone); + } + + [Fact] + public void Build_PreservesPositionOrder() + { + var third = CreateHabit("Third", Today, position: 2); + var first = CreateHabit("First", Today, position: 0); + var second = CreateHabit("Second", Today, position: 1); + + var card = HabitListCardBuilder.Build([third, first, second], Today, HabitListCardBuilder.ScopeAll); + + card.Items.Select(item => item.Title).Should().ContainInOrder("First", "Second", "Third"); + } + + [Fact] + public void Build_CarriesEmojiAndBadHabitFlag() + { + var bad = CreateHabit("Smoking", Today, isBadHabit: true, emoji: "🚬"); + + var card = HabitListCardBuilder.Build([bad], Today, HabitListCardBuilder.ScopeAll); + + var item = card.Items.Single(); + item.Emoji.Should().Be("🚬"); + item.IsBadHabit.Should().BeTrue(); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index debac30e2..b80a1fe2e 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -275,6 +275,67 @@ public async Task Handle_SuccessfulResponse_ReturnsChatResponse() result.Value.Actions.Should().BeEmpty(); } + [Fact] + public async Task Handle_CapableClientWithDirective_PopulatesHabitListAndStripsToken() + { + SetupUserAndPayGate(); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { CreateHabit("Meditate") }.AsReadOnly()); + SetupAiResponse(new AiResponse { TextMessage = "Here are your habits for today:\n[[orbit:habits:today]]", ToolCalls = null }); + var handler = CreateHandler(); + + var command = new ProcessUserChatCommand( + UserId, "what are my habits today", + ClientContext: new AgentClientContext(SupportsHabitListCard: true)); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.AiMessage.Should().Be("Here are your habits for today:"); + result.Value.HabitList.Should().NotBeNull(); + result.Value.HabitList!.Scope.Should().Be("today"); + result.Value.HabitList.Items.Should().ContainSingle(item => item.Title == "Meditate"); + } + + [Fact] + public async Task Handle_DirectiveWithoutCapability_StripsTokenButOmitsHabitList() + { + SetupUserAndPayGate(); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { CreateHabit("Meditate") }.AsReadOnly()); + SetupAiResponse(new AiResponse { TextMessage = "Here are your habits:\n[[orbit:habits:today]]", ToolCalls = null }); + var handler = CreateHandler(); + + var command = new ProcessUserChatCommand(UserId, "what are my habits today"); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.AiMessage.Should().Be("Here are your habits:"); + result.Value.HabitList.Should().BeNull(); + } + + [Fact] + public async Task Handle_CapableClientWithoutDirective_OmitsHabitList() + { + SetupUserAndPayGate(); + SetupAiResponse(new AiResponse { TextMessage = "Sure, I can help.", ToolCalls = null }); + var handler = CreateHandler(); + + var command = new ProcessUserChatCommand( + UserId, "hello", + ClientContext: new AgentClientContext(SupportsHabitListCard: true)); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.AiMessage.Should().Be("Sure, I can help."); + result.Value.HabitList.Should().BeNull(); + } + [Fact] public async Task Handle_PromptBuilderReceivesOnlyActiveHabits() {