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.Lifecycle; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Lifecycle; // The detail pane gates its whole REVIEW/MERGE block (Open diff included) on the worktree row it // last read, and it only re-reads that on WorktreeUpdated -- TaskUpdated refreshes the task row // alone. An interactive session creates/commits its worktree entirely outside the autonomous // pipeline, so without a WorktreeUpdated here the task lands in WaitingForReview with no diff // button at all until it is re-selected. public sealed class InteractiveReviewSubmissionServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly List _repos = new(); private readonly CapturingHubContext _hub = new(); public InteractiveReviewSubmissionServiceTests() => _ctx = _db.CreateContext(); public void Dispose() { foreach (var r in _repos) r.Dispose(); _ctx.Dispose(); _db.Dispose(); } private GitRepoFixture CreateRepo() { var f = new GitRepoFixture(); _repos.Add(f); return f; } private InteractiveReviewSubmissionService BuildSut() { var cfg = new WorkerConfig { SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), }; var git = new GitService(); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; return new InteractiveReviewSubmissionService( dbFactory, git, state, wtManager, new HubBroadcaster(_hub)); } private async Task<(TaskEntity Task, ListEntity List)> SeedAsync(string workingDir, string? handlerBaseCommit) { var lists = new ListRepository(_ctx); var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow }; await lists.AddAsync(list); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "t", Status = TaskStatus.Idle, HandlerBaseCommit = handlerBaseCommit, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await new TaskRepository(_ctx).AddAsync(task); return (task, list); } private bool BroadcastWorktreeUpdatedFor(string taskId) => _hub.Proxy.Calls.Any(c => c.Method == "WorktreeUpdated" && c.Args.Length > 0 && (string?)c.Args[0] == taskId); [Fact] public async Task Submit_WorktreeTask_BroadcastsWorktreeUpdated() { if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var (task, list) = await SeedAsync(repo.RepoDir, handlerBaseCommit: null); var sut = BuildSut(); var cfg = new WorkerConfig { SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), }; var wtManager = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); var wt = await wtManager.CreateAsync(task, list, CancellationToken.None); File.WriteAllText(Path.Combine(wt.WorktreePath, "hand-driven.txt"), "content"); await sut.SubmitAsync(task.Id, CancellationToken.None); Assert.True(BroadcastWorktreeUpdatedFor(task.Id)); } [Fact] public async Task Submit_WorktreeLessHandlerTask_BroadcastsWorktreeUpdated() { if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var (task, _) = await SeedAsync(repo.RepoDir, handlerBaseCommit: repo.BaseCommit); File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled"); await BuildSut().SubmitAsync(task.Id, CancellationToken.None); Assert.True(BroadcastWorktreeUpdatedFor(task.Id)); } [Fact] public async Task Submit_RejectedTask_DoesNotBroadcastWorktreeUpdated() { var (task, _) = await SeedAsync(Path.GetTempPath(), handlerBaseCommit: null); await Assert.ThrowsAsync(() => BuildSut().SubmitAsync(task.Id, CancellationToken.None)); Assert.False(BroadcastWorktreeUpdatedFor(task.Id)); } }