From aa27aeb9cb3863d76c30f8cb85a89eb5f7a8d859 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Wed, 24 Jun 2026 17:19:48 -0300 Subject: [PATCH] feat(chat): emit goal-list card for capable clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the #238 habit-list card for goals: Astra emits a [[orbit:goals]] directive when asked to show/list/review goals; the handler detects it, builds a GoalListCard (active goals with progress + deadline) for clients advertising SupportsGoalListCard, and returns it in ChatResponse. Old clients (no capability flag) keep the prose fallback — append-only contract, no breaking change. Refs thomasluizon/orbit-ui-mobile#301 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Chat/Commands/ProcessUserChatCommand.cs | 17 ++++- src/Orbit.Application/Chat/GoalListCard.cs | 54 +++++++++++++++ src/Orbit.Domain/Models/AgentContracts.cs | 3 +- .../Chat/GoalListCardBuilderTests.cs | 69 +++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/Orbit.Application/Chat/GoalListCard.cs create mode 100644 tests/Orbit.Application.Tests/Chat/GoalListCardBuilderTests.cs diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index bd1fd44bf..b70449453 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -40,7 +40,8 @@ public record ChatResponse( IReadOnlyList? PolicyDenials = null, string? CorrelationId = null, IReadOnlyList? RelatedSurfaces = null, - HabitListCard? HabitList = null); + HabitListCard? HabitList = null, + GoalListCard? GoalList = null); public record ActionResult( string Type, @@ -143,6 +144,14 @@ public async Task> Handle( habitList = HabitListCardBuilder.Build(context.ActiveHabits, context.UserToday, habitListScope); } + GoalListCard? goalList = null; + if (GoalListCardBuilder.TryExtractDirective(aiMessage, out var strippedGoalMessage)) + { + aiMessage = strippedGoalMessage; + if (request.ClientContext?.SupportsGoalListCard == true) + goalList = GoalListCardBuilder.Build(context.ActiveGoals); + } + RunBackgroundPostResponseWork( request.UserId, request.Message, @@ -165,7 +174,8 @@ public async Task> Handle( executionResults.PolicyDenials, request.CorrelationId, executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null, - habitList)); + habitList, + goalList)); } private async Task> LoadChatContextAsync( @@ -274,6 +284,9 @@ private async Task> RequestInitialAiResponseAsync( if (request.ClientContext?.SupportsHabitListCard == true) systemPrompt = string.Join(Environment.NewLine, systemPrompt, HabitListCardBuilder.PromptInstruction); + if (request.ClientContext?.SupportsGoalListCard == true) + systemPrompt = string.Join(Environment.NewLine, systemPrompt, GoalListCardBuilder.PromptInstruction); + var tools = ai.ToolRegistry.GetAll() .OrderBy(t => t.Name, StringComparer.Ordinal) .ToList(); diff --git a/src/Orbit.Application/Chat/GoalListCard.cs b/src/Orbit.Application/Chat/GoalListCard.cs new file mode 100644 index 000000000..ae5af42c9 --- /dev/null +++ b/src/Orbit.Application/Chat/GoalListCard.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Chat; + +public record GoalListCard(IReadOnlyList Items); + +public record GoalListCardItem( + string Id, + string Title, + decimal Current, + decimal Target, + string Unit, + string? Deadline); + +public static partial class GoalListCardBuilder +{ + public const string PromptInstruction = """ + ## Goal list rendering (this client) + This app can display the user's active goals as a live card showing each goal's progress. When the user asks to see, list, or review their goals or goal progress (for example "what are my goals", "show my goals", "how are my goals going", "meus objetivos"), do NOT write the goals out as text and do NOT enumerate them. Instead reply with a brief one-line intro and then, on its own final line, exactly ONE directive token: [[orbit:goals]]. The app replaces the directive with the rendered goal list, so never list the goals yourself when you emit it. 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 TryExtractDirective(string? message, out string stripped) + { + stripped = message ?? string.Empty; + if (string.IsNullOrEmpty(message)) + return false; + + if (!DirectiveRegex().IsMatch(message)) + return false; + + stripped = DirectiveRegex().Replace(message, string.Empty).Trim(); + return true; + } + + public static GoalListCard Build(IReadOnlyList activeGoals) + { + var items = activeGoals + .OrderBy(goal => goal.Position) + .Select(goal => new GoalListCardItem( + goal.Id.ToString(), + goal.Title, + goal.CurrentValue, + goal.TargetValue, + goal.Unit, + goal.Deadline?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))) + .ToList(); + return new GoalListCard(items); + } + + [GeneratedRegex(@"\[\[orbit:goals\]\]", RegexOptions.IgnoreCase)] + private static partial Regex DirectiveRegex(); +} diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index 9ab0edfe3..283956d9b 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -191,7 +191,8 @@ public record AgentClientContext( string? TimeFormat = null, string? CurrentAppArea = null, bool? ShowGeneralOnToday = null, - bool? SupportsHabitListCard = null); + bool? SupportsHabitListCard = null, + bool? SupportsGoalListCard = null); public record AgentContextSnapshot( string Plan, diff --git a/tests/Orbit.Application.Tests/Chat/GoalListCardBuilderTests.cs b/tests/Orbit.Application.Tests/Chat/GoalListCardBuilderTests.cs new file mode 100644 index 000000000..404f1a84f --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/GoalListCardBuilderTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Orbit.Application.Chat; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Tests.Chat; + +public class GoalListCardBuilderTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + + private static Goal CreateGoal( + string title, decimal target, string unit, decimal current = 0, int position = 0, DateOnly? deadline = null) + { + var goal = Goal.Create(new Goal.CreateGoalParams(UserId, title, target, unit, Deadline: deadline, Position: position)).Value; + if (current > 0) + goal.UpdateProgress(current); + return goal; + } + + [Fact] + public void TryExtractDirective_NoDirective_ReturnsFalse() + { + var found = GoalListCardBuilder.TryExtractDirective("Here are your goals.", out var stripped); + + found.Should().BeFalse(); + stripped.Should().Be("Here are your goals."); + } + + [Fact] + public void TryExtractDirective_Directive_ReturnsTrueAndStripsToken() + { + var found = GoalListCardBuilder.TryExtractDirective("Your goals:\n[[orbit:goals]]", out var stripped); + + found.Should().BeTrue(); + stripped.Should().Be("Your goals:"); + } + + [Fact] + public void TryExtractDirective_IsCaseInsensitive() + { + GoalListCardBuilder.TryExtractDirective("[[ORBIT:GOALS]]", out _).Should().BeTrue(); + } + + [Fact] + public void Build_MapsProgressAndPreservesPositionOrder() + { + var second = CreateGoal("Run 100km", 100, "km", current: 40, position: 1, deadline: new DateOnly(2026, 12, 31)); + var first = CreateGoal("Read 12 books", 12, "books", current: 5, position: 0); + + var card = GoalListCardBuilder.Build([second, first]); + + card.Items.Select(item => item.Title).Should().ContainInOrder("Read 12 books", "Run 100km"); + var run = card.Items.Single(item => item.Title == "Run 100km"); + run.Current.Should().Be(40); + run.Target.Should().Be(100); + run.Unit.Should().Be("km"); + run.Deadline.Should().Be("2026-12-31"); + } + + [Fact] + public void Build_NoDeadline_LeavesDeadlineNull() + { + var goal = CreateGoal("Meditate 30 days", 30, "days", current: 12); + + var card = GoalListCardBuilder.Build([goal]); + + card.Items.Single().Deadline.Should().BeNull(); + } +}