Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
IReadOnlyList<AgentPolicyDenial>? PolicyDenials = null,
string? CorrelationId = null,
IReadOnlyList<string>? RelatedSurfaces = null,
HabitListCard? HabitList = null);
HabitListCard? HabitList = null,
GoalListCard? GoalList = null);

public record ActionResult(
string Type,
Expand Down Expand Up @@ -143,6 +144,14 @@
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,
Expand All @@ -165,7 +174,8 @@
executionResults.PolicyDenials,
request.CorrelationId,
executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null,
habitList));
habitList,
goalList));
}

private async Task<Result<ChatContext>> LoadChatContextAsync(
Expand Down Expand Up @@ -274,6 +284,9 @@
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();
Expand Down Expand Up @@ -593,7 +606,7 @@
}
}

private async Task<ToolCallOutcome> StashClarificationAsync(

Check warning on line 609 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 8 parameters, which is greater than the 7 authorized.
AiToolCall call,
ProcessUserChatCommand request,
IPendingClarificationStore clarificationStore,
Expand Down Expand Up @@ -689,7 +702,7 @@
result.EntityName);
}

private static AgentContextSnapshot BuildAgentContextSnapshot(

Check warning on line 705 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 8 parameters, which is greater than the 7 authorized.
User? user,
AgentClientContext? clientContext,
IReadOnlyList<string> featureFlags,
Expand Down Expand Up @@ -823,7 +836,7 @@
return msgEl.GetString();
}
catch (JsonException)
{

Check warning on line 839 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Either remove or fill this block of code.
}

return text;
Expand Down Expand Up @@ -877,7 +890,7 @@
if (operationResult.Status != AgentOperationStatus.Succeeded)
return;

foreach (var surface in ExtractRelatedSurfaces(operationResult.Payload))

Check warning on line 893 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Loops should be simplified using the "Where" LINQ method
{
if (_seenRelatedSurfaces.Add(surface))
_relatedSurfaces.Add(surface);
Expand Down Expand Up @@ -1123,7 +1136,7 @@
{
var node = JsonNode.Parse(args.GetRawText());
if (node is not JsonObject argsObject || argsObject["message"] is not JsonValue messageValue
|| !messageValue.TryGetValue(out string? message) || message is null)

Check warning on line 1139 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Change this condition so that it does not always evaluate to 'False'.
{
return args;
}
Expand Down
54 changes: 54 additions & 0 deletions src/Orbit.Application/Chat/GoalListCard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Orbit.Domain.Entities;

namespace Orbit.Application.Chat;

public record GoalListCard(IReadOnlyList<GoalListCardItem> 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<Goal> 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();
}
3 changes: 2 additions & 1 deletion src/Orbit.Domain/Models/AgentContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions tests/Orbit.Application.Tests/Chat/GoalListCardBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading