feat(worker): warn on near-duplicate titles in add_task/batch_add_tasks
add_task and batch_add_tasks now report up to 3 open (non-terminal)
tasks in the same list with a strongly overlapping title, so a
parallel agent can notice and mention a likely duplicate instead of
silently creating one. The task is always created regardless. Uses a
cheap normalized-word overlap heuristic (no embeddings/LLM call),
robust to German umlaut/digraph spelling variants. Breaking change:
AddTask now returns AddTaskResult { task, possibleDuplicates } instead
of a bare TaskRefDto; BatchAddTaskResult gained a PossibleDuplicates
field.
This commit is contained in:
@@ -142,6 +142,24 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
Assert.Equal(2, inList.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchAddTasks_SimilarTitleInSameList_ReportsPossibleDuplicate()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchAddTasks(listId, new[]
|
||||
{
|
||||
new BatchAddTaskInput("Settings: MaxTurnsCeiling editierbar machen"),
|
||||
}, cancellationToken: CancellationToken.None);
|
||||
|
||||
var result = Assert.Single(results);
|
||||
Assert.True(result.Ok);
|
||||
Assert.NotNull(result.PossibleDuplicates);
|
||||
Assert.Single(result.PossibleDuplicates!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_MissingId_IsFoundFalseNotError()
|
||||
{
|
||||
|
||||
@@ -1518,7 +1518,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
|
||||
var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Id);
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
||||
Assert.Null(loaded!.Model);
|
||||
}
|
||||
|
||||
@@ -1530,7 +1530,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
|
||||
var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Id);
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
||||
Assert.Equal("haiku", loaded!.Model);
|
||||
}
|
||||
|
||||
@@ -1544,6 +1544,80 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
() => sut.AddTask(listId, "t", model: "gpt4", 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_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]
|
||||
|
||||
Reference in New Issue
Block a user