using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Planning; public sealed class PlanningSessionManagerTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly string _rootDir; private readonly GitService _git; private readonly WorkerConfig _cfg; private readonly AppSettingsRepository _settingsRepo; private readonly PlanningSessionManager _sut; private readonly TaskStateServiceBuilder.Built _built; private readonly string _projectsRoot; public PlanningSessionManagerTests() { _projectsRoot = Path.Combine(Path.GetTempPath(), $"cd_projects_{Guid.NewGuid():N}"); _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}"); _git = new GitService(); _cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") }; _settingsRepo = new AppSettingsRepository(_ctx); _settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult(); _built = TaskStateServiceBuilder.Build(_db.CreateFactory()); _sut = new PlanningSessionManager( _tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain, _projectsRoot); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ } try { Directory.Delete(_projectsRoot, recursive: true); } catch { /* ignore */ } } // Mirrors what Claude Code writes: ~/.claude/projects//.jsonl private void WriteTranscript(string cwd, string sessionId, DateTime lastWriteUtc) { var encoded = string.Concat(Path.GetFullPath(cwd) .Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-')); var dir = Path.Combine(_projectsRoot, encoded); Directory.CreateDirectory(dir); var file = Path.Combine(dir, $"{sessionId}.jsonl"); File.WriteAllText(file, "{}\n"); File.SetLastWriteTimeUtc(file, lastWriteUtc); } private async Task<(string listId, string workingDir)> SeedListAsync() { var listId = Guid.NewGuid().ToString(); var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}"); GitRepoFixture.InitRepoWithInitialCommit(wd); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test", WorkingDir = wd, CreatedAt = DateTime.UtcNow, }); return (listId, wd); } private async Task SeedManualTaskAsync(string listId) { var t = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Brainstorm auth", Description = "- review tokens\n- plan rollout", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; await _tasks.AddAsync(t); return t; } [Fact] public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning() { var (listId, wd) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None); Assert.Equal(parent.Id, ctx.ParentTaskId); Assert.Equal(ctx.WorktreePath, ctx.WorkingDir); Assert.True(Directory.Exists(ctx.WorktreePath)); var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json"); Assert.True(File.Exists(mcpPath)); Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json"))); Assert.True(File.Exists(ctx.Files.SystemPromptPath)); Assert.True(File.Exists(ctx.Files.InitialPromptPath)); var mcp = await File.ReadAllTextAsync(mcpPath); Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp); Assert.DoesNotContain(ctx.Token, mcp); var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath); Assert.Contains("Brainstorm auth", initial); Assert.Contains("review tokens", initial); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Idle, loaded!.Status); Assert.Equal(PlanningPhase.Active, loaded.PlanningPhase); Assert.NotNull(loaded.PlanningSessionToken); } [Fact] public async Task StartAsync_TrailingSeparatorOnWorkingDir_PlacesWorktreeOutsideRepo() { var listId = Guid.NewGuid().ToString(); var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}"); GitRepoFixture.InitRepoWithInitialCommit(wd); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test", WorkingDir = wd + Path.DirectorySeparatorChar, CreatedAt = DateTime.UtcNow, }); var parent = await SeedManualTaskAsync(listId); var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None); var wdFull = Path.GetFullPath(wd); Assert.False( ctx.WorktreePath.StartsWith(wdFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), $"worktree path {ctx.WorktreePath} should not be nested inside the repo dir {wdFull}"); Assert.True(Directory.Exists(ctx.WorktreePath)); } [Fact] public async Task StartAsync_TaskNotManual_Throws() { var (listId, _) = await SeedListAsync(); var queuedTask = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "x", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; await _tasks.AddAsync(queuedTask); await Assert.ThrowsAsync(() => _sut.StartAsync(queuedTask.Id, CancellationToken.None)); } [Fact] public async Task StartAsync_ChildTask_Throws() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); await _tasks.SetPlanningStartedAsync(parent.Id, "t"); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null); 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(resumeCtx.WorktreePath, resumeCtx.WorkingDir); Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId); Assert.True(Directory.Exists(resumeCtx.WorktreePath)); Assert.True(File.Exists(Path.Combine(resumeCtx.WorktreePath, ".mcp.json"))); } [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_NoSessionIdAndNoTranscript_Throws() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); await _sut.StartAsync(parent.Id, CancellationToken.None); // Nothing persisted planning_session_id, and no transcript exists on disk. await Assert.ThrowsAsync(() => _sut.ResumeAsync(parent.Id, CancellationToken.None)); } // The interactive TUI never reports its session id, so planning_session_id stays NULL. // Resume has to recover it from the transcript Claude Code left for the planning worktree. [Fact] public async Task ResumeAsync_NoSessionId_RecoversNewestTranscriptAndPersistsIt() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); WriteTranscript(startCtx.WorktreePath, "older-session", new DateTime(2026, 8, 1, 10, 0, 0, DateTimeKind.Utc)); WriteTranscript(startCtx.WorktreePath, "newest-session", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc)); var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None); Assert.Equal("newest-session", resumeCtx.ClaudeSessionId); var reloaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal("newest-session", reloaded!.PlanningSessionId); } // A transcript must never override an id we already captured. [Fact] public async Task ResumeAsync_ExistingSessionId_IgnoresTranscript() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "persisted-session"); WriteTranscript(startCtx.WorktreePath, "transcript-session", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc)); var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None); Assert.Equal("persisted-session", resumeCtx.ClaudeSessionId); } [Fact] public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); await _sut.StartAsync(parent.Id, CancellationToken.None); await _tasks.CreateChildAsync(parent.Id, "c1", null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null); var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None); Assert.Equal(2, count); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase); Assert.NotNull(loaded.PlanningFinalizedAt); Assert.Null(loaded.PlanningSessionToken); } [Fact] public async Task GetPendingDraftCountAsync_ReturnsDraftCount() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); await _sut.StartAsync(parent.Id, CancellationToken.None); await _tasks.CreateChildAsync(parent.Id, "c1", null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null); await _tasks.CreateChildAsync(parent.Id, "c3", null, null); var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None); Assert.Equal(3, n); } [Fact] public async Task DiscardAsync_DeletesSessionDirAndResetsTask() { var (listId, _) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); Assert.True(Directory.Exists(startCtx.Files.SessionDirectory)); await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None); Assert.False(Directory.Exists(startCtx.Files.SessionDirectory)); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Idle, loaded!.Status); Assert.Equal(PlanningPhase.None, loaded.PlanningPhase); Assert.Null(loaded.PlanningSessionToken); } [Fact] public async Task DiscardAsync_RemovesWorktreeAndBranch() { var (listId, wd) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None); Assert.True(Directory.Exists(ctx.WorktreePath)); await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None); Assert.False(Directory.Exists(ctx.WorktreePath)); // branch deleted var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName); Assert.Empty(paths); } [Fact] public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo() { var listId = Guid.NewGuid().ToString(); var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}"); Directory.CreateDirectory(wd); try { await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow }); var t = await SeedManualTaskAsync(listId); await Assert.ThrowsAsync(() => _sut.StartAsync(t.Id, CancellationToken.None)); } finally { try { Directory.Delete(wd, recursive: true); } catch { /* best effort */ } } } [Fact] public async Task StartAsync_SelfHealsWhenBranchAlreadyExists() { var (listId, wd) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); // Pre-create a colliding branch. var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}"; var head = await _git.RevParseHeadAsync(wd); var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd }; procInfo.ArgumentList.Add("branch"); procInfo.ArgumentList.Add(branch); procInfo.ArgumentList.Add(head); var p = System.Diagnostics.Process.Start(procInfo)!; p.WaitForExit(); Assert.True(p.ExitCode == 0, $"git branch setup failed with exit {p.ExitCode}"); var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None); Assert.True(Directory.Exists(ctx.WorktreePath)); } [Fact] public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree() { var (listId, wd) = await SeedListAsync(); var parent = await SeedManualTaskAsync(listId); var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); // Simulate the claude session capturing its session id. await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc"); var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None); Assert.Equal(startCtx.Token, resumeCtx.Token); Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath); Assert.Equal("session-abc", resumeCtx.ClaudeSessionId); } }