feat(worker): accept #123 task numbers as MCP tool input

TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID
before any lookup, across every External/ MCP tool that takes a task
id, including the batch tools' id arrays (via delegation to the
already-resolving single-entity methods) and update_task's
dependsOnTaskId (empty string still passes through unchanged as the
clear-link sentinel). An unknown number throws a clear error instead
of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer
to tasks as #<number> when reporting to the user, added to the
description of get_task, list_tasks, add_task, update_task_status and
review_task.
This commit is contained in:
mika kuns
2026-08-11 13:10:49 +02:00
parent 9660a29da4
commit 2a3133efad
14 changed files with 298 additions and 7 deletions
+3
View File
@@ -44,6 +44,7 @@ public sealed class AttachmentMcpTools
[Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null,
CancellationToken ct = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, ct);
var task = await _tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
@@ -97,6 +98,7 @@ public sealed class AttachmentMcpTools
public async Task<IReadOnlyList<AttachmentDto>> ListTaskAttachments(
string taskId, CancellationToken ct = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, ct);
var rows = await _attachments.ListByTaskIdAsync(taskId, ct);
return rows.Select(r => new AttachmentDto(r.FileName, r.ByteSize, r.CreatedAt)).ToList();
}
@@ -107,6 +109,7 @@ public sealed class AttachmentMcpTools
public async Task<RemoveAttachmentResult> RemoveTaskAttachment(
string taskId, string fileName, CancellationToken ct = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, ct);
var task = await _tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
+3
View File
@@ -147,6 +147,7 @@ public sealed class ConfigMcpTools
IReadOnlyList<string>? clearFields = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -165,6 +166,7 @@ public sealed class ConfigMcpTools
[McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")]
public async Task<TaskConfigResult> GetTaskConfig(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Model is null && task.SystemPrompt is null && task.AgentPath is null && task.MaxTurns is null)
@@ -180,6 +182,7 @@ public sealed class ConfigMcpTools
"to the global ceiling.")]
public async Task<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var listConfig = await _lists.GetConfigAsync(task.ListId, cancellationToken);
+41 -5
View File
@@ -230,7 +230,8 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")]
"List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status." +
McpToolDocs.TaskNumberHint)]
public async Task<ListTasksResult> ListTasks(
string listId,
[Description("Only return tasks with this CreatedBy value.")]
@@ -323,9 +324,11 @@ public sealed class ExternalMcpService
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
"for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
"is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" +
"error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log.")]
"error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." +
McpToolDocs.TaskNumberHint)]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
@@ -336,6 +339,7 @@ public sealed class ExternalMcpService
// path. Not an MCP tool itself — GetTask's own behavior stays untouched.
internal async Task<TaskRefDto> GetTaskRefAsync(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
@@ -345,7 +349,7 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " +
"heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " +
"any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef)]
"any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
public async Task<AddTaskResult> AddTask(
string listId,
string title,
@@ -372,6 +376,8 @@ public sealed class ExternalMcpService
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
var entity = new TaskEntity
@@ -495,6 +501,7 @@ public sealed class ExternalMcpService
string? dependsOnTaskId = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
@@ -507,6 +514,10 @@ public sealed class ExternalMcpService
if (dependsOnTaskId is not null)
{
// Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to
// clear the link — TaskIdResolver passes it through untouched, then NullIfBlank
// below still turns it into the clear signal SetDependsOnAsync expects.
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
@@ -531,6 +542,8 @@ public sealed class ExternalMcpService
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var tasks = new TaskRepository(ctx);
var subtasks = new SubtaskRepository(ctx);
@@ -559,7 +572,8 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " +
"review_task to act on a WaitingForReview task — neither is reachable from here." + McpToolDocs.LeanTaskRef)]
"review_task to act on a WaitingForReview task — neither is reachable from here." +
McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
public async Task<TaskRefDto> UpdateTaskStatus(
string taskId,
[Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
@@ -573,6 +587,7 @@ public sealed class ExternalMcpService
throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -629,7 +644,7 @@ public sealed class ExternalMcpService
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
"delivered something." + McpToolDocs.LeanTaskRef)]
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
@@ -647,6 +662,7 @@ public sealed class ExternalMcpService
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -781,6 +797,7 @@ public sealed class ExternalMcpService
"enqueue via update_task_status instead of retrying in a loop.")]
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
try
{
await _queue.RunNow(taskId);
@@ -809,6 +826,7 @@ public sealed class ExternalMcpService
"Cancel a running task, killing its agent process. cancelled=false means the task was not running.")]
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(taskId);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
@@ -820,6 +838,7 @@ public sealed class ExternalMcpService
McpToolDocs.NotWhileRunning)]
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
@@ -841,6 +860,7 @@ public sealed class ExternalMcpService
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
@@ -871,6 +891,7 @@ public sealed class ExternalMcpService
IReadOnlyList<string>? paths = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
const int maxBytes = 200 * 1024;
@@ -942,6 +963,7 @@ public sealed class ExternalMcpService
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var canMerge = task.Status == TaskStatus.Done ||
@@ -996,6 +1018,7 @@ public sealed class ExternalMcpService
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
@@ -1064,6 +1087,7 @@ public sealed class ExternalMcpService
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -1090,6 +1114,7 @@ public sealed class ExternalMcpService
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -1121,6 +1146,7 @@ public sealed class ExternalMcpService
if (string.IsNullOrEmpty(resolution))
throw new InvalidOperationException("resolution is required.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
@@ -1207,6 +1233,7 @@ public sealed class ExternalMcpService
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
@@ -1237,6 +1264,8 @@ public sealed class ExternalMcpService
if (taskIds is null || taskIds.Count == 0)
throw new InvalidOperationException("taskIds must contain at least one task id.");
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
@@ -1390,6 +1419,7 @@ public sealed class ExternalMcpService
string targetBranch = "main",
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
if (result.Status == TaskMergeService.StatusReverted)
@@ -1433,6 +1463,8 @@ public sealed class ExternalMcpService
bool force = false,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -1467,6 +1499,8 @@ public sealed class ExternalMcpService
if (string.IsNullOrWhiteSpace(followUpPrompt))
throw new InvalidOperationException("followUpPrompt is required.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
string result;
try
{
@@ -1535,6 +1569,8 @@ public sealed class ExternalMcpService
int? sortOrder = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
+3
View File
@@ -40,6 +40,9 @@ public sealed class HandoffMcpTools
if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
survivingTaskIds = await TaskIdResolver.ResolveManyAsync(_tasks, survivingTaskIds, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
+1
View File
@@ -28,6 +28,7 @@ public sealed class LifecycleMcpTools
"— reach for this tool only for a real error, not a task that just ran out of turns.")]
public async Task<ResetFailedTaskResult> ResetFailedTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Failed)
+9
View File
@@ -26,6 +26,15 @@ internal static class McpToolDocs
/// <summary>Mutations that refuse to touch a task while its agent is running.</summary>
public const string NotWhileRunning = " Refused while the task is Running — cancel it first.";
/// <summary>
/// The actual point of task numbers: the payload already carries Number, but the agent never
/// speaks it to the user without being told to. Also documents that #123/bare 123 work
/// anywhere a taskId is expected.
/// </summary>
public const string TaskNumberHint =
" taskId also accepts a task's number (#123 or bare 123). When reporting to the user, refer " +
"to a task as #<number>, not by its GUID.";
/// <summary>
/// Explains this project's diff3 conflict-marker layout so a caller grepping only for
/// "&lt;&lt;&lt;&lt;&lt;&lt;&lt;/=======/&gt;&gt;&gt;&gt;&gt;&gt;&gt;" doesn't mistake the "|||||||" base section for one of the two sides.
+8 -1
View File
@@ -21,8 +21,13 @@ public sealed record TaskLogResult(
public sealed class RunHistoryMcpTools
{
private readonly TaskRunRepository _runs;
private readonly TaskRepository _tasks;
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
public RunHistoryMcpTools(TaskRunRepository runs, TaskRepository tasks)
{
_runs = runs;
_tasks = tasks;
}
[McpServerTool, Description(
"List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " +
@@ -30,6 +35,7 @@ public sealed class RunHistoryMcpTools
"get_run to fetch it individually.")]
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
return runs.Select(ToDto).ToList();
}
@@ -56,6 +62,7 @@ public sealed class RunHistoryMcpTools
int? limit = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken);
if (run is null || string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
return new TaskLogResult(false, [], 0, false);
+46
View File
@@ -0,0 +1,46 @@
using System.Globalization;
using ClaudeDo.Data.Repositories;
namespace ClaudeDo.Worker.External;
/// <summary>
/// Resolves a task-id MCP parameter that may be a display number (<c>#123</c> or bare <c>123</c>)
/// into the task's GUID. A GUID is never all-digits, so the two forms never collide. Anything
/// else — including an empty string, which <c>update_task</c>'s dependsOnTaskId uses as a
/// "clear the link" sentinel — passes through untouched.
/// </summary>
internal static class TaskIdResolver
{
public static async Task<string> ResolveAsync(TaskRepository tasks, string idOrNumber, CancellationToken ct = default)
{
return (await ResolveCoreAsync(tasks, idOrNumber, ct))!;
}
public static async Task<string?> ResolveOptionalAsync(TaskRepository tasks, string? idOrNumber, CancellationToken ct = default)
{
return idOrNumber is null ? null : await ResolveCoreAsync(tasks, idOrNumber, ct);
}
public static async Task<IReadOnlyList<string>> ResolveManyAsync(
TaskRepository tasks, IReadOnlyList<string> idsOrNumbers, CancellationToken ct = default)
{
var resolved = new string[idsOrNumbers.Count];
for (var i = 0; i < idsOrNumbers.Count; i++)
resolved[i] = await ResolveAsync(tasks, idsOrNumbers[i], ct);
return resolved;
}
private static async Task<string?> ResolveCoreAsync(TaskRepository tasks, string idOrNumber, CancellationToken ct)
{
if (idOrNumber.Length == 0)
return idOrNumber;
var candidate = idOrNumber[0] == '#' ? idOrNumber[1..] : idOrNumber;
if (!int.TryParse(candidate, NumberStyles.None, CultureInfo.InvariantCulture, out var number))
return idOrNumber;
var task = await tasks.GetByNumberAsync(number, ct)
?? throw new InvalidOperationException($"no task with number {number}");
return task.Id;
}
}
+13
View File
@@ -1,5 +1,6 @@
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using ModelContextProtocol.Server;
@@ -71,6 +72,8 @@ public sealed class TaskWaitMcpTools
if (taskIds.Length == 0)
throw new ArgumentException("taskIds must not be empty.", nameof(taskIds));
taskIds = await ResolveIdsAsync(taskIds, cancellationToken);
var timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, MaxTimeoutSeconds));
using var timeoutCts = new CancellationTokenSource(timeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
@@ -100,6 +103,16 @@ public sealed class TaskWaitMcpTools
}
}
private async Task<string[]> ResolveIdsAsync(string[] ids, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var tasks = new TaskRepository(ctx);
var resolved = new string[ids.Length];
for (var i = 0; i < ids.Length; i++)
resolved[i] = await TaskIdResolver.ResolveAsync(tasks, ids[i], ct);
return resolved;
}
private async Task<IReadOnlyList<TaskStatusChangeDto>> CheckOnceAsync(
string[] taskIds, bool treatWaitingForChildrenAsBusy, CancellationToken ct)
{
@@ -205,6 +205,22 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Null(found.TaskFull);
}
[Fact]
public async Task BatchGetTasks_MixedNumberAndGuidIds_ResolvesBoth()
{
var listId = await SeedListAsync();
var a = await SeedTaskAsync(listId);
var b = await SeedTaskAsync(listId);
var sut = BuildSut();
var results = await sut.BatchGetTasks(new[] { $"#{a.Number}", b.Id }, cancellationToken: CancellationToken.None);
Assert.True(results[0].Found);
Assert.Equal(a.Id, results[0].Task!.Id);
Assert.True(results[1].Found);
Assert.Equal(b.Id, results[1].Task!.Id);
}
[Fact]
public async Task BatchGetTasks_IncludeDescriptionTrue_ReturnsTaskFull()
{
@@ -229,6 +229,41 @@ public sealed class ExternalMcpServiceTests : IDisposable
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()
{
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Reflection;
using ClaudeDo.Worker.External;
using Microsoft.Extensions.AI;
@@ -71,4 +72,16 @@ public sealed class ExternalMcpToolSchemaTests
// (e.g. namespace/attribute mismatch) and the schema test above would pass vacuously.
Assert.True(ExternalToolMethods().Count() > 20);
}
[Fact]
public void AtLeastOneTaskIdTool_DescriptionCarriesTaskNumberHint()
{
// The whole point of task numbers: without this clause the agent never learns to speak
// #<number> to the user, even though every DTO already carries it.
var hasHint = ExternalToolMethods()
.Select(m => m.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "")
.Any(d => d.Contains(McpToolDocs.TaskNumberHint.Trim(), StringComparison.Ordinal));
Assert.True(hasHint, "No external tool description carries McpToolDocs.TaskNumberHint.");
}
}
@@ -11,13 +11,15 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRunRepository _runs;
private readonly TaskRepository _tasks;
private readonly RunHistoryMcpTools _sut;
public RunHistoryMcpToolsTests()
{
_ctx = _db.CreateContext();
_runs = new TaskRunRepository(_ctx);
_sut = new RunHistoryMcpTools(_runs);
_tasks = new TaskRepository(_ctx);
_sut = new RunHistoryMcpTools(_runs, _tasks);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
@@ -0,0 +1,104 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
public sealed class TaskIdResolverTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public TaskIdResolverTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<TaskEntity> SeedTaskAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task ResolveAsync_HashNumber_ResolvesToGuid()
{
var task = await SeedTaskAsync();
var resolved = await TaskIdResolver.ResolveAsync(_tasks, $"#{task.Number}", CancellationToken.None);
Assert.Equal(task.Id, resolved);
}
[Fact]
public async Task ResolveAsync_BareNumber_ResolvesToGuid()
{
var task = await SeedTaskAsync();
var resolved = await TaskIdResolver.ResolveAsync(_tasks, task.Number.ToString(), CancellationToken.None);
Assert.Equal(task.Id, resolved);
}
[Fact]
public async Task ResolveAsync_Guid_PassesThroughUnchanged()
{
var task = await SeedTaskAsync();
var resolved = await TaskIdResolver.ResolveAsync(_tasks, task.Id, CancellationToken.None);
Assert.Equal(task.Id, resolved);
}
[Fact]
public async Task ResolveAsync_UnknownNumber_ThrowsWithNumberInMessage()
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
TaskIdResolver.ResolveAsync(_tasks, "#999999", CancellationToken.None));
Assert.Contains("999999", ex.Message);
}
[Fact]
public async Task ResolveOptionalAsync_EmptyString_PassesThroughUnchanged()
{
var resolved = await TaskIdResolver.ResolveOptionalAsync(_tasks, "", CancellationToken.None);
Assert.Equal("", resolved);
}
[Fact]
public async Task ResolveOptionalAsync_Null_ReturnsNull()
{
var resolved = await TaskIdResolver.ResolveOptionalAsync(_tasks, null, CancellationToken.None);
Assert.Null(resolved);
}
[Fact]
public async Task ResolveManyAsync_MixedArray_ResolvesBoth()
{
var a = await SeedTaskAsync();
var b = await SeedTaskAsync();
var resolved = await TaskIdResolver.ResolveManyAsync(_tasks, [$"#{a.Number}", b.Id], CancellationToken.None);
Assert.Equal(new[] { a.Id, b.Id }, resolved);
}
}