Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs
T
mika kuns 4ef01274f9 feat(list-handler): hand off to a fresh session after Phase 2
The merge-helper ("Let Claude handle it") system prompt now calls a new
handoff_list_handler MCP tool once every surviving task is enhanced,
instead of continuing into Phases 3-5 in the same session -- avoiding
paying for Phases 0-2's dedupe/rewrite context on every polling round of
the run/review/merge phases.

The tool broadcasts HandoffRequested; Mission Control opens a second
ConPTY tile for the SAME handler task id (no new task, HandlerBaseCommit
untouched) running a fresh handoff brief that starts at Phase 3. The
original tile stays open. Adds PromptKind.MergeHelperHandoff,
InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync, and the
GetMergeHelperHandoffLaunchSpec hub method.
2026-08-05 20:27:39 +02:00

221 lines
9.0 KiB
C#

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 List<string> _mergeHelperSessionDirs = 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();
foreach (var d in _mergeHelperSessionDirs)
try { Directory.Delete(d, true); } catch { /* best effort */ }
}
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"));
}
// ── GetMergeHelperHandoffLaunchSpec ──
[Fact]
public async Task GetMergeHelperHandoffLaunchSpec_ReusesHandlerTaskId_NoNewTaskCreated()
{
var listId = await SeedListAsync(Path.GetTempPath(), name: "Alpha");
var handlerTask = await SeedTaskAsync(listId, TaskStatus.Idle, title: "List handler: Alpha");
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor task");
var hub = CreateHub();
var spec = await hub.GetMergeHelperHandoffLaunchSpec(handlerTask.Id, new[] { survivor.Id });
var args = spec.Args.ToList();
var sessionDir = args[args.IndexOf("--add-dir") + 1];
_mergeHelperSessionDirs.Add(sessionDir);
Assert.Contains("--allowedTools", args);
Assert.Contains("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args);
var kickoff = args[^1];
Assert.Contains(Path.Combine(sessionDir, "handoff.md"), kickoff);
}
[Fact]
public async Task GetMergeHelperHandoffLaunchSpec_UnknownHandlerTask_Throws()
{
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(
() => hub.GetMergeHelperHandoffLaunchSpec("no-such-task", new[] { "x" }));
}
// ── 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.