From ecba12997ab946f8181c48327a957e15159d645a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 20:37:30 +0200 Subject: [PATCH] perf(worker): stop echoing task description from writing MCP tools update_task, update_task_status, add_task, add_subtask, set_my_day, abort_merge, review_task, and their batch variants now return a lean TaskRefDto (id/listId/title/status/sortOrder/isMyDay) instead of the full TaskDto. Those tools were re-sending the caller's own description text back on every call, wasting a large share of session context on list-handler-style runs. get_task/list_tasks/batch_get_tasks are untouched and still return the full DTO. --- src/ClaudeDo.Worker/External/BatchMcpTools.cs | 5 +- .../External/ExternalMcpService.cs | 69 +++++++++++++------ .../External/ExternalMcpServiceTests.cs | 35 ++++++++++ 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index cb224345..7c8fb8bd 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -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 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 BatchCancelResult(string TaskId, bool Ok, bool Cancelled, 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? } " + "(model: haiku|sonnet|opus, blank = inherit list/global default). " + "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> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 9c6bf0f5..962859a6 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -19,7 +19,7 @@ namespace ClaudeDo.Worker.External; public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record DeleteTaskResult(bool Deleted, string Id); public sealed record CancelTaskResult(bool Cancelled, string Id); -public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList MergeConflicts, string? MergeMessage, string? RepoPath = null); +public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList MergeConflicts, string? MergeMessage, string? RepoPath = null); public sealed record StatusValueDto(string Status, string Meaning); public sealed record RunTaskNowResult(bool Started, string TaskId); @@ -37,6 +37,16 @@ public sealed record TaskDto( bool IsMyDay, 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( string Path, string Branch, string HeadCommit, string BaseCommit, 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. " + "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. " + - "Leave model null to inherit the list/global default.")] - public async Task AddTask( + "Leave model null to inherit the list/global default. " + + "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay) — not the description you just sent.")] + public async Task AddTask( string listId, string title, string? description = null, @@ -214,11 +225,14 @@ public sealed class ExternalMcpService } 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.")] - public async Task UpdateTask( + [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. Returns a lean task reference (id, listId, title, status, " + + "sortOrder, isMyDay) — not the description you just sent.")] + public async Task UpdateTask( string taskId, string? title = null, string? description = null, @@ -237,13 +251,14 @@ public sealed class ExternalMcpService var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); - return ToDto(reload); + return ToRefDto(reload); } [McpServerTool, Description( "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.")] - public async Task AddSubtask( + "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " + + "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + public async Task AddSubtask( string taskId, string title, int? orderNum = null, @@ -275,7 +290,7 @@ public sealed class ExternalMcpService }, cancellationToken); await _broadcaster.TaskUpdated(taskId); - return ToDto(task); + return ToRefDto(task); } [McpServerTool, Description( @@ -285,8 +300,9 @@ public sealed class ExternalMcpService "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 " + "and merge that worktree instead). " + - "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] - public async Task UpdateTaskStatus( + "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + + "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + public async Task UpdateTaskStatus( string taskId, string status, CancellationToken cancellationToken) @@ -337,7 +353,7 @@ public sealed class ExternalMcpService } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; - return ToDto(reload); + return ToRefDto(reload); } [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_park' → Idle for manual editing (feedback ignored). " + "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 ReviewTask( string taskId, string decision, @@ -433,7 +450,7 @@ public sealed class ExternalMcpService } return new ReviewTaskResult( - ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!), + ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!), 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. " + "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). " + - "Throws if there is no in-progress merge for the task.")] - public async Task AbortMerge(string taskId, CancellationToken cancellationToken) + "Throws if there is no in-progress merge for the task. " + + "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + public async Task AbortMerge(string taskId, CancellationToken cancellationToken) { _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); @@ -743,7 +761,7 @@ public sealed class ExternalMcpService } await _broadcaster.TaskUpdated(taskId); - return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); + return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); } [McpServerTool, Description( @@ -997,8 +1015,9 @@ public sealed class ExternalMcpService "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " + "(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); " + - "clearing (isMyDay=false) is always allowed.")] - public async Task SetMyDay( + "clearing (isMyDay=false) is always allowed. " + + "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + public async Task SetMyDay( string taskId, bool isMyDay, int? sortOrder = null, @@ -1025,7 +1044,7 @@ public sealed class ExternalMcpService await ctx.SaveChangesAsync(cancellationToken); await _broadcaster.TaskUpdated(taskId); - return ToDto(task); + return ToRefDto(task); } private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( @@ -1120,6 +1139,14 @@ public sealed class ExternalMcpService t.FinishedAt, t.IsMyDay, 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 diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 146539c7..6232a7fb 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -189,6 +189,41 @@ public sealed class ExternalMcpServiceTests : IDisposable 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] public async Task UpdateTask_OnRunning_Throws() {