using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Worktrees; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; internal sealed class ExternalRecordingHubClients : IHubClients { public ExternalRecordingClientProxy Proxy { get; } = new(); public IClientProxy All => Proxy; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Client(string connectionId) => Proxy; public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; public IClientProxy Group(string groupName) => Proxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; public IClientProxy User(string userId) => Proxy; public IClientProxy Users(IReadOnlyList userIds) => Proxy; } internal sealed class ExternalRecordingClientProxy : IClientProxy { public List<(string Method, object?[] Args)> Calls { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Calls.Add((method, args)); return Task.CompletedTask; } } internal sealed class ExternalFakeHubContext : IHubContext { public ExternalRecordingHubClients RecordingClients { get; } = new(); public IHubClients Clients => RecordingClients; public IGroupManager Groups => throw new NotImplementedException(); } public sealed class ExternalMcpServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly ExternalFakeHubContext _hub = new(); private readonly HubBroadcaster _broadcaster; private readonly List _repos = new(); private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public ExternalMcpServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _broadcaster = new HubBroadcaster(_hub); } public void Dispose() { foreach (var (repoDir, wtPath) in _worktreeCleanups) { try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } } foreach (var r in _repos) r.Dispose(); _ctx.Dispose(); _db.Dispose(); } private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync( TaskStatus status = TaskStatus.Done) { var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; await _lists.AddAsync(list); var task = await SeedTaskAsync(listId, status: status); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); var wt = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); return (task, list, wt); } private async Task SeedListAsync(string name = "L") { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow }); return id; } private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } private ExternalMcpService BuildSut(QueueService queue) { var git = new GitService(); var factory = _db.CreateFactory(); var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger.Instance); return new ExternalMcpService( _tasks, _lists, queue, _broadcaster, TaskStateServiceBuilder.Build(factory).State, git, factory, maintenance, merge); } private QueueService CreateQueue() { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = Path.Combine(tempDir, "sandbox"), LogRoot = Path.Combine(tempDir, "logs"), QueueBackstopIntervalMs = 50, }; var fake = new FakeClaudeProcess(); var hubCtx = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hubCtx); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, NullLogger.Instance, state, new TaskRunTokenRegistry()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state); } [Fact] public async Task SeededListAndTask_AreRetrievable() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); Assert.NotNull(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task UpdateTask_PatchesNonNullFieldsOnly() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "old title"); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None); Assert.Equal("new title", dto.Title); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("new title", loaded!.Title); } [Fact] public async Task UpdateTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None)); } [Fact] public async Task UpdateTask_NotFound_Throws() { var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None)); } [Fact] public async Task ReviewTask_Approve_SetsDone() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var dto = await sut.ReviewTask(task.Id, "approve", null, CancellationToken.None); Assert.Equal("Done", dto.Status); } [Fact] public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync(() => sut.ReviewTask(task.Id, "reject_rerun", null, CancellationToken.None)); } [Fact] public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var dto = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", CancellationToken.None); Assert.Equal("Queued", dto.Status); var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal("fix it", loaded!.ReviewFeedback); } [Fact] public async Task ReviewTask_UnknownDecision_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync(() => sut.ReviewTask(task.Id, "bogus", null, CancellationToken.None)); } [Fact] public async Task DeleteTask_RemovesTask() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var queue = CreateQueue(); var sut = BuildSut(queue); await sut.DeleteTask(task.Id, CancellationToken.None); Assert.Null(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task DeleteTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask(task.Id, CancellationToken.None)); } [Fact] public async Task DeleteTask_NotFound_Throws() { var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask("does-not-exist", CancellationToken.None)); } private ExternalMcpService NewService() => BuildSut(CreateQueue()); private async Task SeedIdleTask(string title = "t") { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, title, TaskStatus.Idle); return task.Id; } private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5) { var settings = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ReportExcludedPaths = reportExcludedPaths, DailyPrepMaxTasks = dailyPrepMaxTasks, }; // Upsert via AppSettingsRepository await using var ctx = _db.CreateContext(); var repo = new AppSettingsRepository(ctx); await repo.UpdateAsync(settings); } [Fact] public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo() { // List 1: included repo D:\work\repo — 4 tasks seeded var listId1 = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow }); // idle, not blocked, not MyDay → should be candidate var idleUnblocked = new TaskEntity { Id = "idle-unblocked", ListId = listId1, Title = "Idle unblocked", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(idleUnblocked); // idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor) var idleBlocked = new TaskEntity { Id = "idle-blocked", ListId = listId1, Title = "Idle blocked", Status = TaskStatus.Idle, BlockedByTaskId = "idle-unblocked", CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(idleBlocked); // Done → excluded var doneTask = new TaskEntity { Id = "done-task", ListId = listId1, Title = "Done task", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(doneTask); // idle, IsMyDay → goes into currentMyDay, not candidates var myDayTask = new TaskEntity { Id = "myday-task", ListId = listId1, Title = "MyDay task", Status = TaskStatus.Idle, IsMyDay = true, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(myDayTask); // List 2: excluded repo C:\Private\secret var listId2 = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow }); var excludedRepoTask = new TaskEntity { Id = "excluded-repo-task", ListId = listId2, Title = "Excluded repo", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(excludedRepoTask); // List 3: no WorkingDir → excluded var listId3 = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow }); var noRepoTask = new TaskEntity { Id = "no-repo-task", ListId = listId3, Title = "No repo", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(noRepoTask); await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5); var sut = BuildSut(CreateQueue()); var result = await sut.GetDailyPrepCandidates(CancellationToken.None); Assert.Single(result.Candidates); Assert.Equal("idle-unblocked", result.Candidates[0].Id); Assert.Single(result.CurrentMyDay); Assert.Equal("myday-task", result.CurrentMyDay[0].Id); Assert.Equal(5, result.MaxTasks); } [Fact] public async Task SetMyDay_sets_flag_and_sort_order() { var svc = NewService(); var id = await SeedIdleTask("My task"); var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None); Assert.True(dto.IsMyDay); Assert.Equal(3, dto.SortOrder); } [Fact] public async Task SetMyDay_rejects_when_cap_reached() { await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1); var svc = NewService(); var first = await SeedIdleTask("a"); var second = await SeedIdleTask("b"); await svc.SetMyDay(first, true, null, CancellationToken.None); var ex = await Assert.ThrowsAsync( () => svc.SetMyDay(second, true, null, CancellationToken.None)); Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task SetMyDay_unset_is_always_allowed() { var svc = NewService(); var id = await SeedIdleTask("a"); await svc.SetMyDay(id, true, null, CancellationToken.None); var dto = await svc.SetMyDay(id, false, null, CancellationToken.None); Assert.False(dto.IsMyDay); } // ── GetTaskWorktree ──────────────────────────────────────────────────────── [Fact] public async Task GetTaskWorktree_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.GetTaskWorktree(task.Id, CancellationToken.None)); } [Fact] public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, wt) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); Assert.Equal(wt.BranchName, info.Branch); Assert.Equal(wt.BaseCommit, info.BaseCommit); Assert.Equal(0, info.Ahead); Assert.False(info.IsDirty); } // ── GetTaskDiff ──────────────────────────────────────────────────────────── [Fact] public async Task GetTaskDiff_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.GetTaskDiff(task.Id, false, CancellationToken.None)); } [Fact] public async Task GetTaskDiff_AfterCommit_ListsChangedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None); Assert.Contains("added.txt", diff.Files); Assert.False(diff.Truncated); } // ── MergeTask ────────────────────────────────────────────────────────────── [Fact] public async Task MergeTask_NotDone_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.MergeTask(task.Id, "main", true, false, false, CancellationToken.None)); Assert.Contains("Done", ex.Message); } [Fact] public async Task MergeTask_DryRun_DoesNotMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Null(result.MergeCommit); } // ── ListWorktrees ────────────────────────────────────────────────────────── [Fact] public async Task ListWorktrees_Empty_ReturnsEmpty() { var sut = BuildSut(CreateQueue()); var rows = await sut.ListWorktrees(CancellationToken.None); Assert.Empty(rows); } [Fact] public async Task ListWorktrees_ReturnsCreatedWorktree() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var rows = await sut.ListWorktrees(CancellationToken.None); Assert.Contains(rows, r => r.TaskId == task.Id); } // ── CleanupTaskWorktree ──────────────────────────────────────────────────── [Fact] public async Task CleanupTaskWorktree_NotFound_Throws() { var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None)); } [Fact] public async Task CleanupTaskWorktree_Running_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None)); Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task CleanupTaskWorktree_CleanWorktree_Removes() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None); Assert.True(result.Removed); Assert.False(Directory.Exists(wt.WorktreePath)); } // ── GetTaskConfig ───────────────────────────────────────────────────────── private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster); [Fact] public async Task GetTaskConfig_NotFound_Throws() { var sut = BuildConfigSut(); await Assert.ThrowsAsync( () => sut.GetTaskConfig("does-not-exist", CancellationToken.None)); } [Fact] public async Task GetTaskConfig_NoOverrides_ReturnsNull() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildConfigSut(); var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); Assert.Null(result); } [Fact] public async Task GetTaskConfig_WithOverrides_ReturnsValues() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, CancellationToken.None); var sut = BuildConfigSut(); var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); Assert.NotNull(result); Assert.Equal("claude-sonnet-4-6", result.Model); Assert.Equal("be concise", result.SystemPrompt); Assert.Null(result.AgentPath); Assert.Equal(10, result.MaxTurns); } // ── GetTaskStatusValues ─────────────────────────────────────────────────── [Fact] public async Task GetTaskStatusValues_ContainsAllStatuses() { var sut = NewService(); var values = await sut.GetTaskStatusValues(); var names = values.Select(v => v.Status).ToHashSet(); foreach (var status in Enum.GetValues()) Assert.Contains(status.ToString(), names); } // ── ListTasks status filter ─────────────────────────────────────────────── [Fact] public async Task ListTasks_FilterByWaitingForReview_ReturnsMatchingTasks() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "wfr", TaskStatus.WaitingForReview); await SeedTaskAsync(listId, "idle", TaskStatus.Idle); var sut = NewService(); var result = await sut.ListTasks(listId, null, "WaitingForReview", CancellationToken.None); Assert.Single(result); Assert.Equal("WaitingForReview", result[0].Status); } [Fact] public async Task ListTasks_FilterByWaitingForChildren_ReturnsMatchingTasks() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "wfc", TaskStatus.WaitingForChildren); await SeedTaskAsync(listId, "done", TaskStatus.Done); var sut = NewService(); var result = await sut.ListTasks(listId, null, "WaitingForChildren", CancellationToken.None); Assert.Single(result); Assert.Equal("WaitingForChildren", result[0].Status); } // ── MergeTask allowWaitingForReview ─────────────────────────────────────── [Fact] public async Task MergeTask_WaitingForReview_WithoutFlag_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.MergeTask(task.Id, "main", true, false, false, CancellationToken.None)); Assert.Contains("Done", ex.Message); } [Fact] public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, CancellationToken.None); Assert.False(result.Merged); Assert.Null(result.MergeCommit); } [Fact] public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, CancellationToken.None); Assert.True(result.Merged); var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } // ── AddTask model override ──────────────────────────────────────────────── [Fact] public async Task AddTask_NoModel_LeavesModelNull() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); var loaded = await _tasks.GetByIdAsync(dto.Id); Assert.Null(loaded!.Model); } [Fact] public async Task AddTask_PersistsNormalizedModel() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None); var loaded = await _tasks.GetByIdAsync(dto.Id); Assert.Equal("haiku", loaded!.Model); } [Fact] public async Task AddTask_RejectsUnknownModel() { var listId = await SeedListAsync(); var sut = NewService(); await Assert.ThrowsAsync( () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None)); } // ── ContinueTask validation ─────────────────────────────────────────────── [Fact] public async Task ContinueTask_EmptyPrompt_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = NewService(); await Assert.ThrowsAsync( () => sut.ContinueTask(task.Id, " ", CancellationToken.None)); } }