Merge claudedo/3be80b8f80654fdfadb26372549bb4c3

This commit is contained in:
mika kuns
2026-08-05 09:22:02 +02:00
28 changed files with 1590 additions and 43 deletions
@@ -102,6 +102,9 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> Task.FromResult(Guid.NewGuid().ToString());
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
@@ -0,0 +1,105 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// Covers the worktree-less "list handler" host task (Mission Control's "Let Claude handle
/// it"): it has no WorktreeEntity, so the detail pane's review range must fall back to
/// TaskEntity.HandlerBaseCommit/HandlerHeadCommit, and the merge/diff card must still render.
public class DetailsIslandHandlerRangeTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandHandlerRangeTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handler_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class FakeWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
}
private DetailsIslandViewModel BuildVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
}
[Fact]
public async Task Bind_WorktreeLessHandlerTask_FallsBackToHandlerCommitRange_AndShowsMergeSection()
{
const string listId = "list-1";
const string taskId = "handler-task-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "List handler: L",
Status = TaskStatus.WaitingForReview, IsManual = true,
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.WaitingForReview });
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.WorktreeBaseCommit is null)
await Task.Delay(20);
Assert.Equal("base123", vm.WorktreeBaseCommit);
Assert.Equal("head456", vm.WorktreeHeadCommit);
Assert.Null(vm.WorktreePath);
Assert.True(vm.Merge.ShowMergeSection);
Assert.True(vm.Merge.HasReviewableDiff);
}
}
@@ -523,23 +523,47 @@ public class MissionControlViewModelTests : IDisposable
=> throw new InvalidOperationException("spec failed");
}
private sealed class ThrowingCreateMergeHelperTaskWorker : StubWorkerClient
{
public override Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> throw new InvalidOperationException("create failed");
}
private sealed class FixedTaskIdMergeHelperWorker : StubWorkerClient
{
public string TaskId { get; } = "fixed-handler-task";
public int CreateCallCount { get; private set; }
public override Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
{
CreateCallCount++;
return Task.FromResult(TaskId);
}
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_AddsAdHocPane()
public async Task OpenMergeHelperConPtySessionAsync_AddsTaskBasedPane()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
Assert.Single(vm.ConPtySessions);
Assert.Null(vm.ConPtySessions[0].TaskId);
var pane = Assert.Single(vm.ConPtySessions);
Assert.NotNull(pane.TaskId);
Assert.True(pane.IsTaskBased);
Assert.Single(vm.Panes);
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_NeverDeduped()
public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_EachGetsItsOwnTaskAndPane()
{
// Each run creates a brand-new handler task (the stub returns a fresh Guid per call),
// so two runs still yield two distinct, non-deduped panes -- but for a different reason
// than the old ad-hoc pane (never deduped by construction): dedup is now by TaskId, and
// there simply is no shared TaskId across separate runs.
var worker = new FakeWorker();
using var vm = BuildVm(worker);
@@ -547,6 +571,22 @@ public class MissionControlViewModelTests : IDisposable
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
Assert.Equal(2, vm.ConPtySessions.Count);
Assert.NotEqual(vm.ConPtySessions[0].TaskId, vm.ConPtySessions[1].TaskId);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_SameHandlerTaskId_FocusesExistingPane_NoDuplicate()
{
var worker = new FixedTaskIdMergeHelperWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var firstPane = Assert.Single(vm.ConPtySessions);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
Assert.Single(vm.ConPtySessions);
Assert.Same(firstPane, vm.FocusedPane);
}
[Fact]
@@ -577,6 +617,21 @@ public class MissionControlViewModelTests : IDisposable
Assert.NotNull(error);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_TaskCreationThrows_RaisesErrorReported_NoPaneCreated()
{
var worker = new ThrowingCreateMergeHelperTaskWorker();
using var vm = BuildVm(worker);
string? error = null;
vm.ErrorReported += msg => error = msg;
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
Assert.Empty(vm.ConPtySessions);
Assert.Empty(vm.Panes);
Assert.NotNull(error);
}
[Fact]
public void ToggleLayoutCommand_FlipsIsFocusMode()
{
@@ -656,6 +656,70 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.False(diff.Truncated);
}
[Fact]
public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
// No WorktreeEntity for this task -- it's a worktree-less list-handler host task
// (Mission Control's "Let Claude handle it"): the handler merged its own commit
// straight into the list's working dir after HandlerBaseCommit was stamped.
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = headCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
Assert.Contains("handled.txt", diff.Files);
Assert.False(diff.Truncated);
}
[Fact]
public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(task.Id);
t!.HandlerBaseCommit = repo.BaseCommit;
t.HandlerHeadCommit = headCommit;
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var diff = await sut.GetTaskDiff(task.Id, true, CancellationToken.None);
Assert.Contains("handled.txt", diff.Content);
}
// ── MergeTask ──────────────────────────────────────────────────────────────
[Fact]
@@ -0,0 +1,186 @@
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.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
/// Covers the two hub methods a worktree-less "list handler" host task (Mission Control's
/// "Let Claude handle it") relies on: CreateMergeHelperTask (task creation + HandlerBaseCommit
/// stamp) and SubmitTaskForReview's worktree-less branch (HandlerHeadCommit stamp + transition).
/// The handler itself merges the tasks it handles directly into the list's working dir -- no
/// worktree of its own -- so these hub methods are the whole story for its review range.
public sealed class MergeHelperTaskHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly List<GitRepoFixture> _repos = new();
private readonly RecordingClientProxy _proxy = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public MergeHelperTaskHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_ctx.Dispose();
_db.Dispose();
}
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
{
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
}
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var git = new GitService();
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
new WorkerConfig { ClaudeBin = "claude" });
var built = TaskStateServiceBuilder.Build(factory);
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;
}
private GitRepoFixture CreateRepo()
{
var f = new GitRepoFixture();
_repos.Add(f);
return f;
}
private async Task<string> SeedListAsync(string workingDir, string name = "L")
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = workingDir, CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(
string listId, TaskStatus status = TaskStatus.Idle, string? handlerBaseCommit = null, string title = "T")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
HandlerBaseCommit = handlerBaseCommit,
CreatedAt = DateTime.UtcNow,
};
await _tasks.AddAsync(task);
return task;
}
// ── CreateMergeHelperTask ──
[Fact]
public async Task CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(repo.RepoDir, name: "Alpha");
var t1 = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "First task");
var hub = CreateHub();
var newTaskId = await hub.CreateMergeHelperTask(
new[] { t1.Id }, listId, "List handler: Alpha", "Tasks handled by this run:");
var created = await _tasks.GetByIdAsync(newTaskId);
Assert.NotNull(created);
Assert.Equal(TaskStatus.Idle, created!.Status);
Assert.True(created.IsManual);
Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
Assert.Contains("First task", created.Description);
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
}
[Fact]
public async Task CreateMergeHelperTask_UnknownList_Throws()
{
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(
() => hub.CreateMergeHelperTask(new[] { "t1" }, "no-such-list", "title", "header"));
}
// ── SubmitTaskForReview (worktree-less branch) ──
[Fact]
public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(repo.RepoDir);
var task = await SeedTaskAsync(listId, TaskStatus.Idle, handlerBaseCommit: repo.BaseCommit);
// The handler merged a task's worktree into the list's working dir on its own.
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
var expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var hub = CreateHub();
await hub.SubmitTaskForReview(task.Id);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
Assert.Equal(expectedHead, reloaded.HandlerHeadCommit);
}
[Fact]
public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
}
[Fact]
public async Task SubmitTaskForReview_RunningTask_Throws_RegardlessOfHandlerState()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Running, handlerBaseCommit: "abc123");
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
}
}
// RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the
// whole ClaudeDo.Worker.Tests.Hub namespace in PlanningHubTests.cs; reused here as-is.
@@ -70,6 +70,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
private InteractiveLaunchSpecService BuildService() =>
new(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(),
new WorkerConfig { ClaudeBin = _claudeStubPath });
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
@@ -477,6 +478,65 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(t2, brief);
}
// ── CreateMergeHelperTaskAsync ──
[Fact]
public async Task CreateMergeHelperTaskAsync_EmptyTaskIds_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.CreateMergeHelperTaskAsync(Array.Empty<string>(), listId, "title", "header", CancellationToken.None));
}
[Fact]
public async Task CreateMergeHelperTaskAsync_UnknownList_ThrowsKeyNotFound()
{
var taskId = Guid.NewGuid().ToString();
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.CreateMergeHelperTaskAsync(new[] { taskId }, "no-such-list", "title", "header", CancellationToken.None));
}
[Fact]
public async Task CreateMergeHelperTaskAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.CreateMergeHelperTaskAsync(new[] { taskId }, listId, "title", "header", CancellationToken.None));
}
[Fact]
public async Task CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService();
var newTaskId = await svc.CreateMergeHelperTaskAsync(
new[] { t1 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
using var readCtx = _db.CreateContext();
var created = await new TaskRepository(readCtx).GetByIdAsync(newTaskId);
Assert.NotNull(created);
Assert.Equal("List handler: Alpha", created!.Title);
Assert.Equal(TaskStatus.Idle, created.Status);
Assert.True(created.IsManual);
Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
Assert.Null(created.HandlerHeadCommit);
Assert.Contains("Tasks handled by this run:", created.Description);
Assert.Contains("First task", created.Description);
Assert.Contains(t1, created.Description);
}
[Fact]
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
{
@@ -77,6 +77,9 @@ sealed class FakeWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> Task.FromResult(Guid.NewGuid().ToString());
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public int PlanningStartSpecCalls { get; private set; }