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 @@ -39,7 +39,8 @@
IReadOnlyList<PendingAgentOperation>? PendingOperations = null,
IReadOnlyList<AgentPolicyDenial>? PolicyDenials = null,
string? CorrelationId = null,
IReadOnlyList<string>? RelatedSurfaces = null);
IReadOnlyList<string>? RelatedSurfaces = null,
HabitListCard? HabitList = null);

public record ActionResult(
string Type,
Expand Down Expand Up @@ -134,6 +135,14 @@

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,
Expand All @@ -155,7 +164,8 @@
executionResults.PendingOperations,
executionResults.PolicyDenials,
request.CorrelationId,
executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null));
executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null,
habitList));
}

private async Task<Result<ChatContext>> LoadChatContextAsync(
Expand Down Expand Up @@ -261,6 +271,9 @@
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();
Expand Down Expand Up @@ -622,7 +635,7 @@
result.EntityName);
}

private static AgentContextSnapshot BuildAgentContextSnapshot(

Check warning on line 638 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 @@ -756,7 +769,7 @@
return msgEl.GetString();
}
catch (JsonException)
{

Check warning on line 772 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 @@ -810,7 +823,7 @@
if (operationResult.Status != AgentOperationStatus.Succeeded)
return;

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

Check warning on line 826 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 @@ -1056,7 +1069,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 1072 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
120 changes: 120 additions & 0 deletions src/Orbit.Application/Chat/HabitListCard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.Text.RegularExpressions;
using Orbit.Domain.Entities;

namespace Orbit.Application.Chat;

public record HabitListCard(string Scope, IReadOnlyList<HabitListCardItem> 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<Habit> activeHabits, DateOnly today, string scope)
{
var includedIds = ResolveIncludedIds(activeHabits, today, scope);
var items = new List<HabitListCardItem>();
AppendLevel(activeHabits, parentId: null, depth: 0, today, includedIds, items);
return new HabitListCard(scope, items);
}

private static HashSet<Guid> ResolveIncludedIds(IReadOnlyList<Habit> 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<Guid>();
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<Habit> activeHabits,
Guid? parentId,
int depth,
DateOnly today,
HashSet<Guid> includedIds,
List<HabitListCardItem> 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();
}
3 changes: 2 additions & 1 deletion src/Orbit.Domain/Models/AgentContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
145 changes: 145 additions & 0 deletions tests/Orbit.Application.Tests/Chat/HabitListCardBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
Arg.Any<CancellationToken>())
.Returns(new List<Habit> { 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<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
Arg.Any<CancellationToken>())
.Returns(new List<Habit> { 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()
{
Expand Down
Loading