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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
/// "<<<<<<</=======/>>>>>>>" doesn't mistake the "|||||||" base section for one of the two sides.
|
||||
|
||||
+8
-1
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user