diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 480252d8..a259d79d 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -33,7 +33,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an ` - **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. -- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern: +- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, , ... }`, e.g. `DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`, `RemoveAttachmentResult`; `SetListConfigResult`/`SetTaskConfigResult` additionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicit `Found`/`Available` flag alongside the nullable payload (`TaskConfigResult`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern: - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done` — `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree` - `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items. - `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList` diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs index 940e99c7..b387f09a 100644 --- a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -10,6 +10,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; public sealed record AttachmentDto(string FileName, long ByteSize, DateTime CreatedAt); +public sealed record RemoveAttachmentResult(bool Removed, string TaskId, string FileName); [McpServerToolType] public sealed class AttachmentMcpTools @@ -103,8 +104,8 @@ public sealed class AttachmentMcpTools [McpServerTool, Description( "Remove a single attachment from a task. Deletes both the file on disk and the database record. " + - "Refuses if the task is currently Running — cancel it first.")] - public async Task RemoveTaskAttachment( + "Refuses if the task is currently Running — cancel it first. Returns { removed: true, taskId, fileName } on success.")] + public async Task RemoveTaskAttachment( string taskId, string fileName, CancellationToken ct = default) { var task = await _tasks.GetByIdAsync(taskId, ct) @@ -115,5 +116,6 @@ public sealed class AttachmentMcpTools _store.DeleteFile(taskId, fileName); await _attachments.DeleteAsync(taskId, fileName, ct); await _broadcaster.TaskUpdated(taskId); + return new RemoveAttachmentResult(true, taskId, fileName); } } diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index b13cc518..d19ebbff 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -7,6 +7,9 @@ using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns); +public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config); +public sealed record SetListConfigResult(bool Ok, string ListId, TaskConfigDto? Config); +public sealed record SetTaskConfigResult(bool Ok, string TaskId, TaskConfigDto? Config); [McpServerToolType] public sealed class ConfigMcpTools @@ -22,15 +25,20 @@ public sealed class ConfigMcpTools _broadcaster = broadcaster; } - [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")] - public async Task GetListConfig(string listId, CancellationToken cancellationToken) + [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")] + public async Task GetListConfig(string listId, CancellationToken cancellationToken) { var cfg = await _lists.GetConfigAsync(listId, cancellationToken); - return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns); + return cfg is null + ? new TaskConfigResult(false, null) + : new TaskConfigResult(true, new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns)); } - [McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list config.")] - public async Task SetListConfig( + [McpServerTool, Description( + "Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list " + + "config. Returns { ok, listId, config } — config is null when the config was cleared, otherwise it echoes " + + "the fields that were set (a field is null there if it was individually left unset/cleared).")] + public async Task SetListConfig( string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) { @@ -41,36 +49,52 @@ public sealed class ConfigMcpTools var sp = systemPrompt.NullIfBlank(); var ap = agentPath.NullIfBlank(); + TaskConfigDto? config; if (m is null && sp is null && ap is null && maxTurns is null) + { await _lists.DeleteConfigAsync(listId, cancellationToken); + config = null; + } else + { await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = maxTurns, }, cancellationToken); + config = new TaskConfigDto(m, sp, ap, maxTurns); + } await _broadcaster.ListUpdated(listId); + return new SetListConfigResult(true, listId, config); } - [McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")] - public async Task SetTaskConfig( + [McpServerTool, Description( + "Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to " + + "clear that override. Returns { ok, taskId, config } — config echoes the resulting overrides (a field is " + + "null there if it was cleared or never set).")] + public async Task SetTaskConfig( string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) { _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); - await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, ct: cancellationToken); + var m = model.NullIfBlank(); + var sp = systemPrompt.NullIfBlank(); + var ap = agentPath.NullIfBlank(); + + await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, maxTurns, ct: cancellationToken); await _broadcaster.TaskUpdated(taskId); + return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns)); } - [McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns null if no override is set on this task.")] - public async Task GetTaskConfig(string taskId, CancellationToken cancellationToken) + [McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns { found: false, config: null } if no override is set on this task.")] + public async Task GetTaskConfig(string taskId, CancellationToken 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) - return null; - return new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns); + return new TaskConfigResult(false, null); + return new TaskConfigResult(true, new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns)); } } diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index f3b05eb4..26237593 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -21,6 +21,7 @@ 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 StatusValueDto(string Status, string Meaning); +public sealed record RunTaskNowResult(bool Started, string TaskId); public sealed record TaskDto( string Id, @@ -422,8 +423,8 @@ public sealed class ExternalMcpService mergeStatus, mergeConflicts, mergeMessage, repoPath); } - [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")] - public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) + [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")] + public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) { try { @@ -438,6 +439,7 @@ public sealed class ExternalMcpService throw new InvalidOperationException($"Task {taskId} not found."); } await _broadcaster.TaskUpdated(taskId); + return new RunTaskNowResult(true, taskId); } [McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")] diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index 5fe23389..aa1ecb7a 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -6,6 +6,8 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; +public sealed record ResetFailedTaskResult(bool Reset, string TaskId); + [McpServerToolType] public sealed class LifecycleMcpTools { @@ -18,8 +20,8 @@ public sealed class LifecycleMcpTools _reset = reset; } - [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")] - public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) + [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted. Returns { reset: true, taskId } on success.")] + public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); @@ -27,5 +29,6 @@ public sealed class LifecycleMcpTools throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool."); await _reset.ResetAsync(taskId, cancellationToken); + return new ResetFailedTaskResult(true, taskId); } } diff --git a/src/ClaudeDo.Worker/External/ListMcpTools.cs b/src/ClaudeDo.Worker/External/ListMcpTools.cs index facb9445..72fe5f2f 100644 --- a/src/ClaudeDo.Worker/External/ListMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ListMcpTools.cs @@ -7,6 +7,7 @@ using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); +public sealed record DeleteListResult(bool Deleted, string Id); [McpServerToolType] public sealed class ListMcpTools @@ -61,13 +62,14 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Delete a list and its tasks. Irreversible.")] - public async Task DeleteList(string listId, CancellationToken cancellationToken) + [McpServerTool, Description("Delete a list and its tasks. Irreversible. Returns { deleted: true, id } on success.")] + public async Task DeleteList(string listId, CancellationToken cancellationToken) { _ = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); await _lists.DeleteAsync(listId, cancellationToken); await _broadcaster.ListUpdated(listId); + return new DeleteListResult(true, listId); } private static ListSummaryDto ToDto(ListEntity l) => diff --git a/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs index b4f519a0..97565d2f 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs @@ -134,8 +134,11 @@ public sealed class AttachmentMcpToolsTests : IDisposable var filePath = Path.Combine(_storeRoot, task.Id, "remove.txt"); Assert.True(File.Exists(filePath)); - await sut.RemoveTaskAttachment(task.Id, "remove.txt"); + var result = await sut.RemoveTaskAttachment(task.Id, "remove.txt"); + Assert.True(result.Removed); + Assert.Equal(task.Id, result.TaskId); + Assert.Equal("remove.txt", result.FileName); Assert.False(File.Exists(filePath)); await using var vCtx = _db.CreateContext(); var row = await new TaskAttachmentRepository(vCtx).GetAsync(task.Id, "remove.txt"); diff --git a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs index 403b9d7f..5b414697 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs @@ -37,14 +37,31 @@ public sealed class ConfigMcpToolsTests : IDisposable { var listId = await SeedListAsync(); - await _sut.SetListConfig(listId, "sonnet", "be terse", null, 42, CancellationToken.None); + var setResult = await _sut.SetListConfig(listId, "sonnet", "be terse", null, 42, CancellationToken.None); + Assert.True(setResult.Ok); + Assert.Equal(listId, setResult.ListId); + Assert.NotNull(setResult.Config); + Assert.Equal("sonnet", setResult.Config!.Model); + var cfg = await _sut.GetListConfig(listId, CancellationToken.None); - Assert.NotNull(cfg); - Assert.Equal("sonnet", cfg!.Model); - Assert.Equal("be terse", cfg.SystemPrompt); - Assert.Null(cfg.AgentPath); - Assert.Equal(42, cfg.MaxTurns); + Assert.True(cfg.Found); + Assert.NotNull(cfg.Config); + Assert.Equal("sonnet", cfg.Config!.Model); + Assert.Equal("be terse", cfg.Config.SystemPrompt); + Assert.Null(cfg.Config.AgentPath); + Assert.Equal(42, cfg.Config.MaxTurns); + } + + [Fact] + public async Task GetListConfig_NoConfigSet_ReturnsNotFound() + { + var listId = await SeedListAsync(); + + var cfg = await _sut.GetListConfig(listId, CancellationToken.None); + + Assert.False(cfg.Found); + Assert.Null(cfg.Config); } [Fact] @@ -53,9 +70,12 @@ public sealed class ConfigMcpToolsTests : IDisposable var listId = await SeedListAsync(); await _sut.SetListConfig(listId, "sonnet", null, null, null, CancellationToken.None); - await _sut.SetListConfig(listId, null, null, null, null, CancellationToken.None); + var clearResult = await _sut.SetListConfig(listId, null, null, null, null, CancellationToken.None); - Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None)); + Assert.True(clearResult.Ok); + Assert.Null(clearResult.Config); + var cfg = await _sut.GetListConfig(listId, CancellationToken.None); + Assert.False(cfg.Found); } [Fact] @@ -73,10 +93,65 @@ public sealed class ConfigMcpToolsTests : IDisposable }; await _tasks.AddAsync(task); - await _sut.SetTaskConfig(task.Id, "opus", null, null, 15, CancellationToken.None); + var result = await _sut.SetTaskConfig(task.Id, "opus", null, null, 15, CancellationToken.None); + + Assert.True(result.Ok); + Assert.Equal(task.Id, result.TaskId); + Assert.Equal("opus", result.Config!.Model); + Assert.Equal(15, result.Config.MaxTurns); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("opus", loaded!.Model); Assert.Equal(15, loaded.MaxTurns); } + + [Fact] + public async Task SetTaskConfig_NullField_ClearsThatOverride() + { + var listId = await SeedListAsync(); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + await _sut.SetTaskConfig(task.Id, "opus", "be terse", "agent.md", 15, CancellationToken.None); + + var result = await _sut.SetTaskConfig(task.Id, "opus", null, "agent.md", null, CancellationToken.None); + + Assert.True(result.Ok); + Assert.Equal("opus", result.Config!.Model); + Assert.Null(result.Config.SystemPrompt); + Assert.Equal("agent.md", result.Config.AgentPath); + Assert.Null(result.Config.MaxTurns); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Null(loaded!.SystemPrompt); + Assert.Null(loaded.MaxTurns); + } + + [Fact] + public async Task GetTaskConfig_NoOverrideSet_ReturnsNotFound() + { + var listId = await SeedListAsync(); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + + var cfg = await _sut.GetTaskConfig(task.Id, CancellationToken.None); + + Assert.False(cfg.Found); + Assert.Null(cfg.Config); + } } diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 38b490ec..6921844d 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -832,7 +832,8 @@ public sealed class ExternalMcpServiceTests : IDisposable var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); - Assert.Null(result); + Assert.False(result.Found); + Assert.Null(result.Config); } [Fact] @@ -845,11 +846,12 @@ public sealed class ExternalMcpServiceTests : IDisposable var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); - Assert.NotNull(result); - Assert.Equal("claude-sonnet-4-6", result.Model); - Assert.Equal("be concise", result.SystemPrompt); - Assert.Null(result.AgentPath); - Assert.Equal(10, result.MaxTurns); + Assert.True(result.Found); + Assert.NotNull(result.Config); + Assert.Equal("claude-sonnet-4-6", result.Config!.Model); + Assert.Equal("be concise", result.Config.SystemPrompt); + Assert.Null(result.Config.AgentPath); + Assert.Equal(10, result.Config.MaxTurns); } // ── GetTaskStatusValues ─────────────────────────────────────────────────── diff --git a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs index 03f678bf..5708d7c3 100644 --- a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs @@ -64,8 +64,10 @@ public sealed class LifecycleMcpToolsTests : IDisposable var task = await SeedTaskAsync(TaskStatus.Failed); var sut = BuildSut(); - await sut.ResetFailedTask(task.Id, CancellationToken.None); + var result = await sut.ResetFailedTask(task.Id, CancellationToken.None); + Assert.True(result.Reset); + Assert.Equal(task.Id, result.TaskId); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Idle, loaded!.Status); } diff --git a/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs index 50f1f05d..c4efdb68 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs @@ -59,8 +59,10 @@ public sealed class ListMcpToolsTests : IDisposable { var created = await _sut.CreateList("gone", null, null, CancellationToken.None); - await _sut.DeleteList(created.Id, CancellationToken.None); + var result = await _sut.DeleteList(created.Id, CancellationToken.None); + Assert.True(result.Deleted); + Assert.Equal(created.Id, result.Id); Assert.Null(await _lists.GetByIdAsync(created.Id)); } }