using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; namespace ClaudeDo.Worker.Tests.Runner; /// Verifies TaskRunner persists the resolved model (task -> list -> AppSettings.DefaultModel) /// onto the task_runs row it creates, since the hub's usage-by-model report reads it back from there. public sealed class RunModelPersistenceTests : IDisposable { private readonly DbFixture _db = new(); private readonly string _tempDir; private readonly WorkerConfig _cfg; public RunModelPersistenceTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"cd_runmodel_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir }; } public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } } private TaskRunner BuildRunner() { var dbFactory = _db.CreateFactory(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); return new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); } private async Task SeedAsync(string? taskModel, string? listModel) { using var ctx = _db.CreateContext(); ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); if (listModel is not null) ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", Model = listModel }); ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Task", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, Model = taskModel, }); await ctx.SaveChangesAsync(); } private async Task RunAndGetPersistedModelAsync() { var runner = BuildRunner(); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); using var readCtx = _db.CreateContext(); var run = await new TaskRunRepository(readCtx).GetLatestByTaskIdAsync("t1"); return run?.Model; } [Fact] public async Task Task_level_model_override_is_persisted_on_the_run() { await SeedAsync(taskModel: "opus", listModel: "haiku"); var persisted = await RunAndGetPersistedModelAsync(); Assert.Equal("opus", persisted); } [Fact] public async Task List_default_model_is_persisted_when_task_has_no_override() { await SeedAsync(taskModel: null, listModel: "haiku"); var persisted = await RunAndGetPersistedModelAsync(); Assert.Equal("haiku", persisted); } [Fact] public async Task Global_default_model_is_persisted_when_task_and_list_have_no_override() { await SeedAsync(taskModel: null, listModel: null); string globalDefault; using (var ctx = _db.CreateContext()) { var settings = await new AppSettingsRepository(ctx).GetAsync(); globalDefault = settings.DefaultModel; } var persisted = await RunAndGetPersistedModelAsync(); Assert.Equal(globalDefault, persisted); } }