diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 7080960..67194ab 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -53,6 +53,25 @@ public sealed class PlanningSessionManager return new PlanningSessionStartContext(taskId, list.WorkingDir, files); } + public async Task ResumeAsync(string taskId, CancellationToken ct) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Planning) + throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning."); + if (string.IsNullOrEmpty(task.PlanningSessionId)) + throw new InvalidOperationException("No Claude session ID captured yet; cannot resume."); + + var sessionDir = Path.Combine(_rootDirectory, taskId); + var mcpConfigPath = Path.Combine(sessionDir, "mcp.json"); + if (!File.Exists(mcpConfigPath)) + throw new InvalidOperationException($"Session directory missing: {sessionDir}"); + + var list = await _lists.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException($"List {task.ListId} not found."); + return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath); + } + private static string GenerateToken() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index e9c16c7..a587d89 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -120,4 +120,43 @@ public sealed class PlanningSessionManagerTests : IDisposable await Assert.ThrowsAsync(() => _sut.StartAsync(child.Id, CancellationToken.None)); } + + [Fact] + public async Task ResumeAsync_ReturnsExistingSessionDetails() + { + var (listId, wd) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42"); + + var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None); + + Assert.Equal(parent.Id, resumeCtx.ParentTaskId); + Assert.Equal(wd, resumeCtx.WorkingDir); + Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId); + Assert.Equal(startCtx.Files.McpConfigPath, resumeCtx.McpConfigPath); + Assert.True(File.Exists(resumeCtx.McpConfigPath)); + } + + [Fact] + public async Task ResumeAsync_NotPlanning_Throws() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + // did not start + await Assert.ThrowsAsync(() => + _sut.ResumeAsync(parent.Id, CancellationToken.None)); + } + + [Fact] + public async Task ResumeAsync_NoClaudeSessionId_Throws() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + // UpdatePlanningSessionIdAsync not called + + await Assert.ThrowsAsync(() => + _sut.ResumeAsync(parent.Id, CancellationToken.None)); + } } \ No newline at end of file