diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs index e76f75c2..aee4f6a9 100644 --- a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -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> 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 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) diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index c38ea56e..440b9f5b 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -147,6 +147,7 @@ public sealed class ConfigMcpTools IReadOnlyList? 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 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 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); diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 3a9f4ba6..b727fc25 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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 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 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 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 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 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 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 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 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 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 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? 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 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 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 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(); var filesByTask = new Dictionary>(); var numbersByTask = new Dictionary(); @@ -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) diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index a4545440..6f5fada7 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -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."); diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index 4aba973d..7e56d812 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -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 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) diff --git a/src/ClaudeDo.Worker/External/McpToolDocs.cs b/src/ClaudeDo.Worker/External/McpToolDocs.cs index 6d6c7d12..5b570049 100644 --- a/src/ClaudeDo.Worker/External/McpToolDocs.cs +++ b/src/ClaudeDo.Worker/External/McpToolDocs.cs @@ -26,6 +26,15 @@ internal static class McpToolDocs /// Mutations that refuse to touch a task while its agent is running. public const string NotWhileRunning = " Refused while the task is Running — cancel it first."; + /// + /// 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. + /// + 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 #, not by its GUID."; + /// /// Explains this project's diff3 conflict-marker layout so a caller grepping only for /// "<<<<<<</=======/>>>>>>>" doesn't mistake the "|||||||" base section for one of the two sides. diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs index d334fc7a..9dd31566 100644 --- a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -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> 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); diff --git a/src/ClaudeDo.Worker/External/TaskIdResolver.cs b/src/ClaudeDo.Worker/External/TaskIdResolver.cs new file mode 100644 index 00000000..252656c9 --- /dev/null +++ b/src/ClaudeDo.Worker/External/TaskIdResolver.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using ClaudeDo.Data.Repositories; + +namespace ClaudeDo.Worker.External; + +/// +/// Resolves a task-id MCP parameter that may be a display number (#123 or bare 123) +/// into the task's GUID. A GUID is never all-digits, so the two forms never collide. Anything +/// else — including an empty string, which update_task's dependsOnTaskId uses as a +/// "clear the link" sentinel — passes through untouched. +/// +internal static class TaskIdResolver +{ + public static async Task ResolveAsync(TaskRepository tasks, string idOrNumber, CancellationToken ct = default) + { + return (await ResolveCoreAsync(tasks, idOrNumber, ct))!; + } + + public static async Task ResolveOptionalAsync(TaskRepository tasks, string? idOrNumber, CancellationToken ct = default) + { + return idOrNumber is null ? null : await ResolveCoreAsync(tasks, idOrNumber, ct); + } + + public static async Task> ResolveManyAsync( + TaskRepository tasks, IReadOnlyList 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 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; + } +} diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index 47a9f7d9..7079672a 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -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 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> CheckOnceAsync( string[] taskIds, bool treatWaitingForChildrenAsBusy, CancellationToken ct) { diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index c4cad937..66490135 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 228a5620..38360877 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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(() => + sut.GetTask("#999999", CancellationToken.None)); + + Assert.Contains("999999", ex.Message); + } + [Fact] public async Task GetTask_ReturnsFullDtoIncludingDescription() { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs index 8be47df3..fd95dc2a 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs @@ -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 + // # to the user, even though every DTO already carries it. + var hasHint = ExternalToolMethods() + .Select(m => m.GetCustomAttribute()?.Description ?? "") + .Any(d => d.Contains(McpToolDocs.TaskNumberHint.Trim(), StringComparison.Ordinal)); + + Assert.True(hasHint, "No external tool description carries McpToolDocs.TaskNumberHint."); + } } diff --git a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs index 25cc5f09..aa3a4bd9 100644 --- a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs @@ -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(); } diff --git a/tests/ClaudeDo.Worker.Tests/External/TaskIdResolverTests.cs b/tests/ClaudeDo.Worker.Tests/External/TaskIdResolverTests.cs new file mode 100644 index 00000000..9a9dc66f --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/TaskIdResolverTests.cs @@ -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 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(() => + 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); + } +}