1615 lines
66 KiB
C#
1615 lines
66 KiB
C#
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.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Tests.Services;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ModelContextProtocol;
|
|
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<string> excludedConnectionIds) => Proxy;
|
|
public IClientProxy Client(string connectionId) => Proxy;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
|
public IClientProxy Group(string groupName) => Proxy;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
|
public IClientProxy User(string userId) => Proxy;
|
|
public IClientProxy Users(IReadOnlyList<string> 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<WorkerHub>
|
|
{
|
|
public ExternalRecordingHubClients RecordingClients { get; } = new();
|
|
public IHubClients Clients => RecordingClients;
|
|
public IGroupManager Groups => throw new NotImplementedException();
|
|
}
|
|
|
|
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
|
|
// captured at construction, which is unreliable to assert on immediately in a test.
|
|
file sealed class SyncProgress<T> : IProgress<T>
|
|
{
|
|
public readonly List<T> Reports = new();
|
|
public void Report(T value) => Reports.Add(value);
|
|
}
|
|
|
|
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<GitRepoFixture> _repos = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
private int _numberSeed;
|
|
|
|
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<WorktreeManager>.Instance);
|
|
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private async Task<string> 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<TaskEntity> SeedTaskAsync(
|
|
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Status = status,
|
|
ParentTaskId = parentId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
Number = ++_numberSeed,
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private ExternalMcpService BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner());
|
|
|
|
private ExternalMcpService BuildSut(QueueService queue, IVerifyCommandRunner verify)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
return new ExternalMcpService(
|
|
_tasks, _lists, queue, _broadcaster,
|
|
state,
|
|
git, factory, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
}
|
|
|
|
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<WorktreeManager>.Instance);
|
|
var argsBuilder = new ClaudeArgsBuilder();
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
|
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
|
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
var usageState = new UsageState();
|
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), usageState, broadcaster,
|
|
new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
|
|
}
|
|
|
|
[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, null, null, cancellationToken: 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_ReturnsLeanReference_NotFullTaskDto()
|
|
{
|
|
// TaskRefDto has no Description/Result properties -- a writing tool that echoes it back
|
|
// would re-send the (possibly long) description the caller just sent.
|
|
Assert.DoesNotContain(nameof(TaskDto.Description), typeof(TaskRefDto).GetProperties().Select(p => p.Name));
|
|
Assert.DoesNotContain("Result", typeof(TaskRefDto).GetProperties().Select(p => p.Name));
|
|
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, "old title");
|
|
task.Description = "a long description the caller already has";
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, dto.Id);
|
|
Assert.Equal(listId, dto.ListId);
|
|
Assert.Equal("new title", dto.Title);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_SetsIsManualTrue()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, isManual: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(dto.IsManual);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.True(loaded!.IsManual);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_SetsIsManualFalse_ClearsExistingFlag()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.IsManual = true;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, isManual: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(dto.IsManual);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.False(loaded!.IsManual);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_IsManualNull_LeavesUnchanged()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.IsManual = true;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, title: "new title", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(dto.IsManual);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_SetsExpectsNoDiffTrue()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, expectsNoDiff: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(dto.ExpectsNoDiff);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.True(loaded!.ExpectsNoDiff);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_SetsExpectsNoDiffFalse_ClearsExistingFlag()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.ExpectsNoDiff = true;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, expectsNoDiff: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(dto.ExpectsNoDiff);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.False(loaded!.ExpectsNoDiff);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_ExpectsNoDiffNull_LeavesUnchanged()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.ExpectsNoDiff = true;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, title: "new title", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(dto.ExpectsNoDiff);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_ReturnsTaskNumber()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(task.Number, dto.Number);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_ByHashNumber_ResolvesToTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask($"#{task.Number}", CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, dto.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_ByBareNumber_ResolvesToTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Number.ToString(), CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, dto.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_UnknownNumber_ThrowsWithNumberInMessage()
|
|
{
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.GetTask("#999999", CancellationToken.None));
|
|
|
|
Assert.Contains("999999", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_ReturnsFullDtoIncludingDescription()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, "with description");
|
|
task.Description = "the full description text";
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("the full description text", dto.Description);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_Failed_ReturnsClassifiedFailureReason()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
|
|
task.FailureReason = "max_turns";
|
|
task.FailureTurnsUsed = 55;
|
|
task.FailureMaxTurns = 60;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("max_turns", dto.FailureReason);
|
|
Assert.Equal(55, dto.FailureTurnsUsed);
|
|
Assert.Equal(60, dto.FailureMaxTurns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError()
|
|
{
|
|
// A Failed task written before this field existed has FailureReason=null in the DB —
|
|
// the MCP surface must still hand back a defined value, not null or a throw.
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("unknown", dto.FailureReason);
|
|
Assert.Null(dto.FailureTurnsUsed);
|
|
Assert.Null(dto.FailureMaxTurns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_NotFailed_FailureReasonIsNull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Null(dto.FailureReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason()
|
|
{
|
|
// GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be
|
|
// present there, not just on the full get_task DTO.
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
|
|
task.FailureReason = "error";
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("error", dto.FailureReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.True(dto.Blocked);
|
|
Assert.Contains(predecessor.Id, dto.BlockedReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(dto.Blocked);
|
|
Assert.Null(dto.BlockedReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
|
|
{
|
|
// Blocking is only meaningful once the picker would actually be gating the task --
|
|
// an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet.
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(dto.Blocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_ReportsBlockedPerTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
|
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
|
blocked.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
|
var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);
|
|
|
|
var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id);
|
|
var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id);
|
|
Assert.True(blockedRef.Blocked);
|
|
Assert.Contains(predecessor.Id, blockedRef.BlockedReason);
|
|
Assert.False(unblockedRef.Blocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
|
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
|
blocked.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
var dto = result.TasksFull!.Single(t => t.Id == blocked.Id);
|
|
Assert.True(dto.Blocked);
|
|
}
|
|
|
|
[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<InvalidOperationException>(() =>
|
|
sut.UpdateTask(task.Id, "x", null, null, null, null, cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_NotFound_Throws()
|
|
{
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.UpdateTask("does-not-exist", "x", null, null, null, null, cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(predecessor.Id, dto.DependsOnTaskId);
|
|
Assert.True(dto.Blocked);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);
|
|
|
|
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(dto.DependsOnTaskId);
|
|
Assert.False(dto.Blocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var a = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var b = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: 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 result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
}
|
|
|
|
[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<InvalidOperationException>(() =>
|
|
sut.ReviewTask(task.Id, "reject_rerun", null, null, cancellationToken: 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 result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal("Queued", result.Task.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<InvalidOperationException>(() =>
|
|
sut.ReviewTask(task.Id, "bogus", null, null, cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch()
|
|
{
|
|
if (!GitAvailable) return;
|
|
var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview);
|
|
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature");
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var row = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
|
row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim();
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt")));
|
|
using var verify = _db.CreateContext();
|
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State);
|
|
}
|
|
|
|
// Regression coverage for the incident this fixes: an approve whose post-merge verify gate
|
|
// runs long enough gets no MCP traffic at all until RunVerifyGateAsync returns, so Claude
|
|
// Code's ~300s idle-silence abort kills the call -- the merge had already landed and
|
|
// committed, but the task never reached Done and every dependsOn successor stayed blocked
|
|
// until someone noticed and force-set the status by hand. Shrinks TaskMergeService's own
|
|
// progress interval (not TaskWaitMcpTools') to observe a report without waiting 30s real time.
|
|
[Fact]
|
|
public async Task ReviewTask_ApproveWithSlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = TaskMergeService.ProgressReportInterval;
|
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
|
try
|
|
{
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "ok"),
|
|
Delay = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var reports = new List<ProgressNotificationValue>();
|
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
|
|
|
var result = await sut.ReviewTask(
|
|
task.Id, "approve", null, null, cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
// Progress<T> marshals via the SynchronizationContext captured at construction; give
|
|
// any queued callbacks a beat to run before asserting on `reports`.
|
|
await Task.Delay(200);
|
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
|
}
|
|
finally
|
|
{
|
|
TaskMergeService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_ApproveWithVerifyCommand_NoProgressToken_DoesNotThrow()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge()
|
|
{
|
|
if (!GitAvailable) return;
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var parentId = Guid.NewGuid().ToString();
|
|
var childId = Guid.NewGuid().ToString();
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{childId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "child.txt");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = childId, Path = wtPath, BranchName = branch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt")));
|
|
using var verify = _db.CreateContext();
|
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
|
}
|
|
|
|
// Regression coverage for the parent/children unit-merge path specifically: PlanningMergeOrchestrator
|
|
// used to drain every child's merge (and verify gate) without ever forwarding review_task's own
|
|
// progress token, so a multi-child approve with a slow verify command reported only the single
|
|
// "review_task started" tick and then went silent for as long as every child's gate took combined.
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ParentWithChildren_SlowVerifyCommand_ReportsProgress()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = TaskMergeService.ProgressReportInterval;
|
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
|
try
|
|
{
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var parentId = Guid.NewGuid().ToString();
|
|
var childId = Guid.NewGuid().ToString();
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{childId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "child.txt");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = childId, Path = wtPath, BranchName = branch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "ok"),
|
|
Delay = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var reports = new List<ProgressNotificationValue>();
|
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
|
|
|
var result = await sut.ReviewTask(
|
|
parentId, "approve", null, "main", cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
await Task.Delay(200);
|
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
|
}
|
|
finally
|
|
{
|
|
TaskMergeService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ParentWithChildren_ReportsEmptyChildByName()
|
|
{
|
|
// A blocked child (CLAUDEDO_BLOCKED, committed nothing) still gets an active worktree at
|
|
// its base commit -- TaskRunner creates one before the run starts -- so "empty" shows up
|
|
// as zero commits ahead of base, not as a missing worktree.
|
|
if (!GitAvailable) return;
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
var parentId = Guid.NewGuid().ToString();
|
|
var normalChildId = Guid.NewGuid().ToString();
|
|
var blockedChildId = Guid.NewGuid().ToString();
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1, Number = ++_numberSeed });
|
|
|
|
var normalWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, normalWtPath));
|
|
var normalBranch = $"claudedo/{normalChildId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", normalBranch, normalWtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(normalWtPath, "child.txt"), "c\n");
|
|
GitRepoFixture.RunGit(normalWtPath, "add", "child.txt");
|
|
GitRepoFixture.RunGit(normalWtPath, "commit", "-m", "add child.txt");
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = normalChildId, Path = normalWtPath, BranchName = normalBranch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(normalWtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
|
|
var blockedWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, blockedWtPath));
|
|
var blockedBranch = $"claudedo/{blockedChildId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", blockedBranch, blockedWtPath, repo.BaseCommit);
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = blockedChildId, Path = blockedWtPath, BranchName = blockedBranch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = repo.BaseCommit,
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
Assert.NotNull(result.EmptyChildren);
|
|
var empty = Assert.Single(result.EmptyChildren!);
|
|
Assert.Equal(blockedChildId, empty.Id);
|
|
Assert.DoesNotContain(result.EmptyChildren!, c => c.Id == normalChildId);
|
|
}
|
|
|
|
[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<InvalidOperationException>(() =>
|
|
sut.DeleteTask(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_WithChildren_ThrowsUnderstandableMessage()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
|
|
await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.DeleteTask(parent.Id, CancellationToken.None));
|
|
|
|
Assert.Contains("child task", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.NotNull(await _tasks.GetByIdAsync(parent.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_NotFound_Throws()
|
|
{
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
|
|
await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id);
|
|
var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await sut.DeleteTask(lastChild.Id, CancellationToken.None);
|
|
|
|
var reloadedParent = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
|
|
|
|
Assert.Equal("Cancelled", dto.Status);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_WaitingForReview_StillRejected()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None));
|
|
Assert.Contains("not settable externally", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None);
|
|
|
|
Assert.Equal("Done", dto.Status);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
|
Assert.NotNull(loaded.FinishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws()
|
|
{
|
|
if (!GitAvailable) return;
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
|
|
|
Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Idle_OnRunningTask_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.UpdateTaskStatus(task.Id, "Idle", CancellationToken.None));
|
|
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Running, loaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTaskStatus_Done_OnRunningTask_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
|
|
|
Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Running, loaded!.Status);
|
|
}
|
|
|
|
private ExternalMcpService NewService() => BuildSut(CreateQueue());
|
|
|
|
private async Task<string> 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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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<InvalidOperationException>(
|
|
() => 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);
|
|
}
|
|
|
|
// ── GetTaskConfig ─────────────────────────────────────────────────────────
|
|
|
|
private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory());
|
|
|
|
[Fact]
|
|
public async Task GetTaskConfig_NotFound_Throws()
|
|
{
|
|
var sut = BuildConfigSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => 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.False(result.Found);
|
|
Assert.Null(result.Config);
|
|
}
|
|
|
|
[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, ct: CancellationToken.None);
|
|
var sut = BuildConfigSut();
|
|
|
|
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
|
|
|
|
Assert.True(result.Found);
|
|
Assert.NotNull(result.Config);
|
|
Assert.Equal("claude-sonnet-4-6", result.Config!.Model);
|
|
Assert.Equal("be concise", result.Config.SystemPrompt);
|
|
Assert.Null(result.Config.AgentPath);
|
|
Assert.Equal(10, result.Config.MaxTurns);
|
|
}
|
|
|
|
// ── 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: CancellationToken.None);
|
|
|
|
Assert.NotNull(result.Tasks);
|
|
Assert.Single(result.Tasks!);
|
|
Assert.Equal("WaitingForReview", result.Tasks![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: CancellationToken.None);
|
|
|
|
Assert.NotNull(result.Tasks);
|
|
Assert.Single(result.Tasks!);
|
|
Assert.Equal("WaitingForChildren", result.Tasks![0].Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_Default_ReturnsLeanReferences_NoDescriptionOrTasksFull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, "with desc");
|
|
task.Description = "a long description that should not come back by default";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.IncludeDescription);
|
|
Assert.NotNull(result.Tasks);
|
|
Assert.Null(result.TasksFull);
|
|
Assert.Single(result.Tasks!);
|
|
Assert.Equal(task.Id, result.Tasks![0].Id);
|
|
Assert.Equal(task.Number, result.Tasks![0].Number);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_IncludeDescriptionTrue_ReturnsFullTasks()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, "with desc");
|
|
task.Description = "the full description";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(result.IncludeDescription);
|
|
Assert.Null(result.Tasks);
|
|
Assert.NotNull(result.TasksFull);
|
|
Assert.Single(result.TasksFull!);
|
|
Assert.Equal("the full description", result.TasksFull![0].Description);
|
|
Assert.Equal(task.Number, result.TasksFull![0].Number);
|
|
}
|
|
|
|
// ── leaveConflictsInTree ──────────────────────────────────────────────────
|
|
|
|
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync(
|
|
TaskStatus status = TaskStatus.WaitingForReview)
|
|
{
|
|
var (task, list, wt) = await SeedWorktreeAsync(status);
|
|
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
|
|
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
|
|
|
|
return (task, list, wt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_LeaveConflictsInTree_LeavesMarkersAndReportsRepoPath()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
|
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
|
Assert.Contains("continue_merge", result.MergeMessage);
|
|
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
Assert.Equal("WaitingForReview", result.Task.Status);
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ConflictWithoutLeaveFlag_KeepsOldBehaviour()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
|
|
Assert.Null(result.RepoPath);
|
|
Assert.Contains("ClaudeDo UI", result.MergeMessage);
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
Assert.Equal("WaitingForReview", result.Task.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_ReturnsAllocatedTaskNumber()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
|
Assert.True(dto.Task.Number > 0);
|
|
Assert.Equal(loaded!.Number, dto.Task.Number);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_DefaultExpectsNoDiff_IsFalse()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(dto.Task.ExpectsNoDiff);
|
|
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
|
Assert.False(loaded!.ExpectsNoDiff);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_ExpectsNoDiffTrue_PersistsFlag()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(listId, "t", expectsNoDiff: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(dto.Task.ExpectsNoDiff);
|
|
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
|
Assert.True(loaded!.ExpectsNoDiff);
|
|
}
|
|
|
|
// ── 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.Task.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.Task.Id);
|
|
Assert.Equal("haiku", loaded!.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_RejectsUnknownModel()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(
|
|
() => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(
|
|
listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId);
|
|
Assert.True(dto.Task.Blocked);
|
|
Assert.Contains(predecessor.Id, dto.Task.BlockedReason);
|
|
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
|
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws()
|
|
{
|
|
// Can't reference its own not-yet-known id, so this exercises the not-found path --
|
|
// an unknown dependsOnTaskId is rejected the same way a self-reference would be.
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
// ── AddTask possible-duplicate check ────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task AddTask_SimilarOpenTitleInSameList_IsReportedAsPossibleDuplicate()
|
|
{
|
|
// The exact pair that motivated this check (bf4cd901 vs f3718cd8, see task write-up).
|
|
var listId = await SeedListAsync();
|
|
var existing = await SeedTaskAsync(
|
|
listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.WaitingForReview);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.AddTask(
|
|
listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);
|
|
|
|
var dup = Assert.Single(result.PossibleDuplicates);
|
|
Assert.Equal(existing.Id, dup.TaskId);
|
|
Assert.Equal(existing.Title, dup.Title);
|
|
Assert.Equal("WaitingForReview", dup.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_StillCreatesTheTask_EvenWhenAPossibleDuplicateIsFound()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.AddTask(
|
|
listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.NotEmpty(result.PossibleDuplicates);
|
|
Assert.NotNull(await _tasks.GetByIdAsync(result.Task.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_SimilarTitle_ButExistingTaskIsTerminal_IsNotReported()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Done);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.AddTask(
|
|
listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Empty(result.PossibleDuplicates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_ClearlyDifferentTitle_IsNotReported()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "Refactor GitService worktree cleanup", TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.AddTask(
|
|
listId, "Add dark mode toggle to the settings page", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Empty(result.PossibleDuplicates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_TitlesSharingOnlyOneWordPlusTheGermanStopwordFuer_AreNotReportedAsDuplicates()
|
|
{
|
|
// Regression for the "fur" (never-transliterated) stopword typo: NormalizeTitleWords
|
|
// transliterates "für" -> "fuer" before the stopword check, so a stopword list containing
|
|
// "fur" can never filter it. Before the fix these two titles shared "report" and "fuer"
|
|
// (2 words, 50% of the shorter title) and cleared the duplicate threshold undeservedly;
|
|
// with "fuer" correctly filtered as filler only "report" remains shared (1 word).
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "Neuer Report für Bestand", TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.AddTask(
|
|
listId, "Anderer Report für Kunden", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Empty(result.PossibleDuplicates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_MoreThanThreeMatches_CapsPossibleDuplicatesAtThree()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
for (var i = 0; i < 5; i++)
|
|
await SeedTaskAsync(listId, $"Fix flaky queue picker test number {i}", TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var result = await sut.AddTask(
|
|
listId, "Fix flaky queue picker test number extra", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(3, result.PossibleDuplicates.Count);
|
|
}
|
|
|
|
// ── ContinueTask validation ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ContinueTask_EmptyPrompt_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = NewService();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ContinueTask(task.Id, " ", CancellationToken.None));
|
|
}
|
|
}
|