feat(mcp/planning): allow status changes and post-finalize edits in active session

Extend UpdateChildTask with a status parameter (restricted to Draft, Manual,
Queued, Waiting) and replace the 'only Draft is editable' rule with 'planning
session is active'. Same loosening applied to DeleteChildTask. Lets planning
agents iterate on children that already escaped Draft state.
This commit is contained in:
Mika Kuns
2026-04-27 10:16:32 +02:00
parent 721c36a66b
commit 2d7f825ff3
2 changed files with 99 additions and 14 deletions

View File

@@ -60,27 +60,41 @@ public sealed class PlanningMcpService
return list;
}
[McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")]
private static readonly TaskStatus[] EditableStatuses =
{ TaskStatus.Draft, TaskStatus.Manual, TaskStatus.Queued, TaskStatus.Waiting };
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Manual, Queued, Waiting.")]
public async Task<ChildTaskDto> UpdateChildTask(
string taskId,
string? title,
string? description,
IReadOnlyList<string>? tags,
string? commitType,
string? status,
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
?? throw new InvalidOperationException("Planning parent task not found.");
if (parent.Status != TaskStatus.Planning)
throw new InvalidOperationException("Cannot modify tasks outside an active planning session.");
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (child.ParentTaskId != ctx.ParentTaskId)
throw new InvalidOperationException("Task is not a child of this planning session.");
if (child.Status != TaskStatus.Draft)
throw new InvalidOperationException("Cannot modify a finalized task.");
if (title is not null) child.Title = title;
if (description is not null) child.Description = description;
if (commitType is not null) child.CommitType = commitType;
await _tasks.UpdateAsync(child, cancellationToken);
TaskStatus? newStatus = null;
if (!string.IsNullOrEmpty(status))
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException($"Unknown status '{status}'.");
if (!EditableStatuses.Contains(parsed))
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Manual, Queued, Waiting.");
newStatus = parsed;
}
await _tasks.UpdateChildAsync(taskId, title, description, commitType, tags, newStatus, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
@@ -89,18 +103,21 @@ public sealed class PlanningMcpService
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
}
[McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")]
[McpServerTool, Description("Delete a child task in the active planning session.")]
public async Task DeleteChildTask(
string taskId,
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
?? throw new InvalidOperationException("Planning parent task not found.");
if (parent.Status != TaskStatus.Planning)
throw new InvalidOperationException("Cannot delete tasks outside an active planning session.");
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (child.ParentTaskId != ctx.ParentTaskId)
throw new InvalidOperationException("Task is not a child of this planning session.");
if (child.Status != TaskStatus.Draft)
throw new InvalidOperationException("Cannot delete a finalized task.");
await _tasks.DeleteAsync(taskId, cancellationToken);
await BroadcastTaskUpdatedAsync(taskId, cancellationToken);

View File

@@ -138,11 +138,11 @@ public sealed class PlanningMcpServiceTests : IDisposable
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None));
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_NotDraft_Throws()
public async Task UpdateChildTask_AfterFinalize_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
@@ -150,7 +150,75 @@ public sealed class PlanningMcpServiceTests : IDisposable
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_SetsTags()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "agent", "custom-tag" }, null, null, CancellationToken.None);
Assert.Contains("agent", result.Tags);
Assert.Contains("custom-tag", result.Tags);
Assert.Equal(2, result.Tags.Count);
}
[Fact]
public async Task UpdateChildTask_ReplacesTagSet()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, new[] { "agent" }, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "manual" }, null, null, CancellationToken.None);
Assert.Single(result.Tags);
Assert.Equal("manual", result.Tags[0]);
}
[Fact]
public async Task UpdateChildTask_SetsStatus()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, null, null, "Queued", CancellationToken.None);
Assert.Equal("Queued", result.Status);
var loaded = await _tasks.GetByIdAsync(c.Id);
Assert.Equal(TaskStatus.Queued, loaded!.Status);
}
[Fact]
public async Task UpdateChildTask_DisallowedStatus_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "Running", CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_UnknownStatus_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "NotARealStatus", CancellationToken.None));
}
[Fact]
@@ -215,7 +283,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None);
await sut.UpdateChildTask(c.Id, "new title", null, null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);