From 13d8773704b20691639aa136019a5d91ea1c0f39 Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 21:13:50 +0000 Subject: [PATCH] Add ConversationLog (start/append/end with monotonic per-convo index) --- backend.tests/ConversationLogTests.cs | 71 ++++++++++++++++++++++++ backend/Conversations/ConversationLog.cs | 63 +++++++++++++++++++++ backend/Program.cs | 1 + 3 files changed, 135 insertions(+) create mode 100644 backend.tests/ConversationLogTests.cs create mode 100644 backend/Conversations/ConversationLog.cs diff --git a/backend.tests/ConversationLogTests.cs b/backend.tests/ConversationLogTests.cs new file mode 100644 index 0000000..c468f03 --- /dev/null +++ b/backend.tests/ConversationLogTests.cs @@ -0,0 +1,71 @@ +using backend.Conversations; +using backend.Data; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace backend.tests; + +public class ConversationLogTests(ApiFactory factory) : IClassFixture +{ + private readonly ApiFactory _factory = factory; + + private async Task<(AppDbContext db, ConversationLog log, Guid deviceId)> ArrangeAsync() + { + var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var log = scope.ServiceProvider.GetRequiredService(); + var deviceId = Guid.NewGuid(); + return (db, log, deviceId); + } + + [Fact] + public async Task Start_creates_conversation_row() + { + var (db, log, deviceId) = await ArrangeAsync(); + + var convo = await log.StartAsync(deviceId, default); + + var row = await db.Conversations.FirstAsync(c => c.Id == convo.Id); + row.DeviceId.Should().Be(deviceId); + row.EndedAt.Should().BeNull(); + row.EndReason.Should().Be(EndReason.Unknown); + } + + [Fact] + public async Task Turns_are_persisted_with_monotonic_index_per_conversation() + { + var (db, log, deviceId) = await ArrangeAsync(); + var convo = await log.StartAsync(deviceId, default); + + await log.AppendUserAsync(convo.Id, "hi", default); + await log.AppendAssistantAsync(convo.Id, "hello there", default); + await log.AppendToolAsync(convo.Id, "get_current_time", "{}", "{\"now\":\"2026-06-11T00:00:00Z\"}", default); + + var turns = await db.Turns + .Where(t => t.ConversationId == convo.Id) + .OrderBy(t => t.Index) + .ToListAsync(); + + turns.Should().HaveCount(3); + turns.Select(t => t.Index).Should().Equal(0, 1, 2); + turns.Select(t => t.Role).Should().Equal(TurnRole.User, TurnRole.Assistant, TurnRole.Tool); + turns[0].Text.Should().Be("hi"); + turns[1].Text.Should().Be("hello there"); + turns[2].ToolName.Should().Be("get_current_time"); + } + + [Fact] + public async Task End_sets_ended_at_and_reason() + { + var (db, log, deviceId) = await ArrangeAsync(); + var convo = await log.StartAsync(deviceId, default); + + await log.EndAsync(convo.Id, EndReason.Idle, default); + + var row = await db.Conversations.FirstAsync(c => c.Id == convo.Id); + row.EndedAt.Should().NotBeNull(); + row.EndReason.Should().Be(EndReason.Idle); + } +} diff --git a/backend/Conversations/ConversationLog.cs b/backend/Conversations/ConversationLog.cs new file mode 100644 index 0000000..9bb9f13 --- /dev/null +++ b/backend/Conversations/ConversationLog.cs @@ -0,0 +1,63 @@ +using backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace backend.Conversations; + +public class ConversationLog(AppDbContext db) +{ + public async Task StartAsync(Guid deviceId, CancellationToken ct) + { + var c = new Conversation + { + DeviceId = deviceId, + StartedAt = DateTimeOffset.UtcNow, + }; + db.Conversations.Add(c); + await db.SaveChangesAsync(ct); + return c; + } + + public Task AppendUserAsync(Guid conversationId, string text, CancellationToken ct) + => AppendAsync(conversationId, TurnRole.User, text: text, ct: ct); + + public Task AppendAssistantAsync(Guid conversationId, string text, CancellationToken ct) + => AppendAsync(conversationId, TurnRole.Assistant, text: text, ct: ct); + + public Task AppendToolAsync( + Guid conversationId, string toolName, string argsJson, string resultJson, CancellationToken ct) + => AppendAsync(conversationId, TurnRole.Tool, + toolName: toolName, argsJson: argsJson, resultJson: resultJson, ct: ct); + + public async Task EndAsync(Guid conversationId, EndReason reason, CancellationToken ct) + { + var row = await db.Conversations.FirstAsync(c => c.Id == conversationId, ct); + row.EndedAt = DateTimeOffset.UtcNow; + row.EndReason = reason; + await db.SaveChangesAsync(ct); + } + + private async Task AppendAsync( + Guid conversationId, TurnRole role, + string? text = null, string? toolName = null, + string? argsJson = null, string? resultJson = null, + CancellationToken ct = default) + { + var next = await db.Turns + .Where(t => t.ConversationId == conversationId) + .Select(t => (int?)t.Index) + .MaxAsync(ct) ?? -1; + + db.Turns.Add(new Turn + { + ConversationId = conversationId, + Index = next + 1, + Role = role, + Text = text, + ToolName = toolName, + ToolArgsJson = argsJson, + ToolResultJson = resultJson, + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(ct); + } +} diff --git a/backend/Program.cs b/backend/Program.cs index 49c8d64..3c69ec4 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -33,6 +33,7 @@ builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); var app = builder.Build();