Merge claudedo/01556d1f8af54fe2afa4bced3a209121

This commit is contained in:
mika kuns
2026-08-05 22:41:44 +02:00
3 changed files with 86 additions and 23 deletions
+3 -2
View File
@@ -7,7 +7,7 @@ public sealed record BatchAddTaskInput(string Title, string? Description = null,
public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null);
public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error); public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error);
public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskDto? Task, string? Error); public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskRefDto? Task, string? Error);
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error); public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error);
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error); public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error); public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error);
@@ -62,7 +62,8 @@ public sealed class BatchMcpTools
"Create many tasks in one list at once. Each item: { title, description?, model? } " + "Create many tasks in one list at once. Each item: { title, description?, model? } " +
"(model: haiku|sonnet|opus, blank = inherit list/global default). " + "(model: haiku|sonnet|opus, blank = inherit list/global default). " +
"queueImmediately enqueues every created task. " + "queueImmediately enqueues every created task. " +
"Returns one result per item: { index, title, ok, task, error }. Max 100 items.")] "Returns one result per item: { index, title, ok, task, error }; task is a lean reference " +
"(id, listId, title, status, sortOrder, isMyDay), not the description you just sent. Max 100 items.")]
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks( public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
string listId, string listId,
BatchAddTaskInput[] tasks, BatchAddTaskInput[] tasks,
+48 -21
View File
@@ -19,7 +19,7 @@ namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id); public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id); public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null); public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null);
public sealed record StatusValueDto(string Status, string Meaning); public sealed record StatusValueDto(string Status, string Meaning);
public sealed record RunTaskNowResult(bool Started, string TaskId); public sealed record RunTaskNowResult(bool Started, string TaskId);
@@ -37,6 +37,16 @@ public sealed record TaskDto(
bool IsMyDay, bool IsMyDay,
int SortOrder); int SortOrder);
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
// without re-sending Description/Result, which the caller just sent or already has.
public sealed record TaskRefDto(
string Id,
string ListId,
string Title,
string Status,
int SortOrder,
bool IsMyDay);
public sealed record WorktreeInfoDto( public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit, string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
@@ -171,8 +181,9 @@ public sealed class ExternalMcpService
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " + "Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " + "Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " + "'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default.")] "Leave model null to inherit the list/global default. " +
public async Task<TaskDto> AddTask( "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay) — not the description you just sent.")]
public async Task<TaskRefDto> AddTask(
string listId, string listId,
string title, string title,
string? description = null, string? description = null,
@@ -214,11 +225,14 @@ public sealed class ExternalMcpService
} }
await _broadcaster.TaskUpdated(entity.Id); await _broadcaster.TaskUpdated(entity.Id);
return ToDto(entity); return ToRefDto(entity);
} }
[McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")] [McpServerTool, Description(
public async Task<TaskDto> UpdateTask( "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. " +
"Refuses if the task is currently Running. Returns a lean task reference (id, listId, title, status, " +
"sortOrder, isMyDay) — not the description you just sent.")]
public async Task<TaskRefDto> UpdateTask(
string taskId, string taskId,
string? title = null, string? title = null,
string? description = null, string? description = null,
@@ -237,13 +251,14 @@ public sealed class ExternalMcpService
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return ToDto(reload); return ToRefDto(reload);
} }
[McpServerTool, Description( [McpServerTool, Description(
"Append a subtask (step) to a task. orderNum defaults to the end. " + "Append a subtask (step) to a task. orderNum defaults to the end. " +
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")] "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " +
public async Task<TaskDto> AddSubtask( "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> AddSubtask(
string taskId, string taskId,
string title, string title,
int? orderNum = null, int? orderNum = null,
@@ -275,7 +290,7 @@ public sealed class ExternalMcpService
}, cancellationToken); }, cancellationToken);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return ToDto(task); return ToRefDto(task);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -285,8 +300,9 @@ public sealed class ExternalMcpService
"Cancelled (retire the task without deleting it; it can be reset to Idle later), " + "Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " + "Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
"and merge that worktree instead). " + "and merge that worktree instead). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
public async Task<TaskDto> UpdateTaskStatus( "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> UpdateTaskStatus(
string taskId, string taskId,
string status, string status,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -337,7 +353,7 @@ public sealed class ExternalMcpService
} }
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
return ToDto(reload); return ToRefDto(reload);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -352,7 +368,8 @@ public sealed class ExternalMcpService
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " + "decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " + "decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " + "decision='cancel' → Cancelled. " +
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")] "Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued). " +
"The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<ReviewTaskResult> ReviewTask( public async Task<ReviewTaskResult> ReviewTask(
string taskId, string taskId,
string decision, string decision,
@@ -433,7 +450,7 @@ public sealed class ExternalMcpService
} }
return new ReviewTaskResult( return new ReviewTaskResult(
ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!), ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
mergeStatus, mergeConflicts, mergeMessage, repoPath); mergeStatus, mergeConflicts, mergeMessage, repoPath);
} }
@@ -725,8 +742,9 @@ public sealed class ExternalMcpService
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " + "Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " + "Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " + "unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
"Throws if there is no in-progress merge for the task.")] "Throws if there is no in-progress merge for the task. " +
public async Task<TaskDto> AbortMerge(string taskId, CancellationToken cancellationToken) "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
{ {
_ = await _tasks.GetByIdAsync(taskId, cancellationToken) _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -743,7 +761,7 @@ public sealed class ExternalMcpService
} }
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -997,8 +1015,9 @@ public sealed class ExternalMcpService
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " + "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
"(use consecutive sortOrder values to keep related tasks together). " + "(use consecutive sortOrder values to keep related tasks together). " +
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " + "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
"clearing (isMyDay=false) is always allowed.")] "clearing (isMyDay=false) is always allowed. " +
public async Task<TaskDto> SetMyDay( "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> SetMyDay(
string taskId, string taskId,
bool isMyDay, bool isMyDay,
int? sortOrder = null, int? sortOrder = null,
@@ -1025,7 +1044,7 @@ public sealed class ExternalMcpService
await ctx.SaveChangesAsync(cancellationToken); await ctx.SaveChangesAsync(cancellationToken);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return ToDto(task); return ToRefDto(task);
} }
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
@@ -1120,6 +1139,14 @@ public sealed class ExternalMcpService
t.FinishedAt, t.FinishedAt,
t.IsMyDay, t.IsMyDay,
t.SortOrder); t.SortOrder);
private static TaskRefDto ToRefDto(TaskEntity t) => new(
t.Id,
t.ListId,
t.Title,
t.Status.ToString(),
t.SortOrder,
t.IsMyDay);
} }
internal static class DailyPrepFilter internal static class DailyPrepFilter
@@ -189,6 +189,41 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("new title", loaded!.Title); Assert.Equal("new title", loaded!.Title);
} }
[Fact]
public async Task UpdateTask_ReturnsLeanReference_NotFullTaskDto()
{
// TaskRefDto has no Description/Result properties -- a writing tool that echoes it back
// would re-send the (possibly long) description the caller just sent.
Assert.DoesNotContain(nameof(TaskDto.Description), typeof(TaskRefDto).GetProperties().Select(p => p.Name));
Assert.DoesNotContain("Result", typeof(TaskRefDto).GetProperties().Select(p => p.Name));
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "old title");
task.Description = "a long description the caller already has";
await _tasks.UpdateAsync(task, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None);
Assert.Equal(task.Id, dto.Id);
Assert.Equal(listId, dto.ListId);
Assert.Equal("new title", dto.Title);
}
[Fact]
public async Task GetTask_ReturnsFullDtoIncludingDescription()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "with description");
task.Description = "the full description text";
await _tasks.UpdateAsync(task, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTask(task.Id, CancellationToken.None);
Assert.Equal("the full description text", dto.Description);
}
[Fact] [Fact]
public async Task UpdateTask_OnRunning_Throws() public async Task UpdateTask_OnRunning_Throws()
{ {