From 5a03dc8430882a2e62e2c371c31e852063eedd27 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 24 Apr 2026 14:54:46 +0200 Subject: [PATCH] feat(worker): broadcast child TaskUpdated events on planning CRUD So the UI refreshes individual child rows alongside the parent during create/update/delete/finalize from the planning MCP service. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Planning/PlanningMcpService.cs | 7 ++ .../Planning/PlanningMcpServiceTests.cs | 103 +++++++++++++++--- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index 302fe08..5a19c62 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -40,6 +40,7 @@ public sealed class PlanningMcpService { var ctx = _contextAccessor.Current; var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); + await BroadcastTaskUpdatedAsync(child.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new CreatedChildDto(child.Id, "Draft"); } @@ -83,6 +84,7 @@ public sealed class PlanningMcpService var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken); + await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); } @@ -101,6 +103,7 @@ public sealed class PlanningMcpService throw new InvalidOperationException("Cannot delete a finalized task."); await _tasks.DeleteAsync(taskId, cancellationToken); + await BroadcastTaskUpdatedAsync(taskId, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } @@ -121,7 +124,11 @@ public sealed class PlanningMcpService CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; + var childIds = (await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken)) + .Select(c => c.Id).ToList(); var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); + foreach (var id in childIds) + await BroadcastTaskUpdatedAsync(id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return count; } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index 837912a..3d9d08f 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -16,29 +16,34 @@ file sealed class FakeHttpContextAccessor : IHttpContextAccessor public HttpContext? HttpContext { get; set; } } -file sealed class NullHubClients : IHubClients +file sealed class RecordingHubClients : IHubClients { - public IClientProxy All => NullClientProxy.Instance; - public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; - public IClientProxy Client(string connectionId) => NullClientProxy.Instance; - public IClientProxy Clients(IReadOnlyList connectionIds) => NullClientProxy.Instance; - public IClientProxy Group(string groupName) => NullClientProxy.Instance; - public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; - public IClientProxy Groups(IReadOnlyList groupNames) => NullClientProxy.Instance; - public IClientProxy User(string userId) => NullClientProxy.Instance; - public IClientProxy Users(IReadOnlyList userIds) => NullClientProxy.Instance; + public RecordingClientProxy Proxy { get; } = new(); + public IClientProxy All => Proxy; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; + public IClientProxy Client(string connectionId) => Proxy; + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; + public IClientProxy Group(string groupName) => Proxy; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; + public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; + public IClientProxy User(string userId) => Proxy; + public IClientProxy Users(IReadOnlyList userIds) => Proxy; } -file sealed class NullClientProxy : IClientProxy +file sealed class RecordingClientProxy : IClientProxy { - public static readonly NullClientProxy Instance = new(); + public List<(string Method, object?[] Args)> Calls { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) - => Task.CompletedTask; + { + Calls.Add((method, args)); + return Task.CompletedTask; + } } file sealed class FakeHubContext : IHubContext { - public IHubClients Clients { get; } = new NullHubClients(); + public RecordingHubClients RecordingClients { get; } = new(); + public IHubClients Clients => RecordingClients; public IGroupManager Groups => throw new NotImplementedException(); } @@ -58,15 +63,25 @@ public sealed class PlanningMcpServiceTests : IDisposable public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + private List<(string Method, object?[] Args)> _hubCalls = new(); + private PlanningMcpService BuildSut(string parentTaskId) { var httpContext = new DefaultHttpContext(); httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId }; var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext }); - var broadcaster = new HubBroadcaster(new FakeHubContext()); + var hub = new FakeHubContext(); + _hubCalls = hub.RecordingClients.Proxy.Calls; + var broadcaster = new HubBroadcaster(hub); return new PlanningMcpService(_tasks, accessor, broadcaster); } + private IReadOnlyList TaskUpdatedIds() => + _hubCalls + .Where(c => c.Method == "TaskUpdated") + .Select(c => (string)c.Args[0]!) + .ToList(); + private async Task SeedPlanningParentAsync() { var listId = Guid.NewGuid().ToString(); @@ -178,4 +193,62 @@ public sealed class PlanningMcpServiceTests : IDisposable Assert.Equal(TaskStatus.Planned, loaded!.Status); Assert.Null(loaded.PlanningSessionToken); } + + [Fact] + public async Task CreateChildTask_BroadcastsBothChildAndParent() + { + var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); + + var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None); + + var ids = TaskUpdatedIds(); + Assert.Contains(result.TaskId, ids); + Assert.Contains(parent.Id, ids); + } + + [Fact] + public async Task UpdateChildTask_BroadcastsBothChildAndParent() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + _ctx.ChangeTracker.Clear(); + + var sut = BuildSut(parent.Id); + await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None); + + var ids = TaskUpdatedIds(); + Assert.Contains(c.Id, ids); + Assert.Contains(parent.Id, ids); + } + + [Fact] + public async Task DeleteChildTask_BroadcastsBothChildAndParent() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + var sut = BuildSut(parent.Id); + await sut.DeleteChildTask(c.Id, CancellationToken.None); + + var ids = TaskUpdatedIds(); + Assert.Contains(c.Id, ids); + Assert.Contains(parent.Id, ids); + } + + [Fact] + public async Task Finalize_BroadcastsEachChildAndParent() + { + var parent = await SeedPlanningParentAsync(); + var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var sut = BuildSut(parent.Id); + await sut.Finalize(true, CancellationToken.None); + + var ids = TaskUpdatedIds(); + Assert.Contains(c1.Id, ids); + Assert.Contains(c2.Id, ids); + Assert.Contains(parent.Id, ids); + } }