Add ConversationLog (start/append/end with monotonic per-convo index)

This commit is contained in:
2026-06-11 21:13:50 +00:00
parent 99cfe60a2c
commit 13d8773704
3 changed files with 135 additions and 0 deletions
+71
View File
@@ -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<ApiFactory>
{
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<AppDbContext>();
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
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);
}
}
+63
View File
@@ -0,0 +1,63 @@
using backend.Data;
using Microsoft.EntityFrameworkCore;
namespace backend.Conversations;
public class ConversationLog(AppDbContext db)
{
public async Task<Conversation> 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);
}
}
+1
View File
@@ -33,6 +33,7 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
builder.Services.AddScoped<backend.Pairing.PairingService>();
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
builder.Services.AddScoped<backend.Conversations.ConversationLog>();
var app = builder.Build();