using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Runner; /// Verifies InteractiveLaunchSpecService's guards mirror WorkerHub.ResumeTaskInTerminal /// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the /// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and /// produces --resume for a resumable task vs. a fresh-start spec for a never-run task. /// Never spawns the real claude CLI: ClaudeBin points at a stub file on disk. public sealed class InteractiveLaunchSpecServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly string _tempDir; private readonly string _worktreeDir; private readonly string _claudeStubPath; private readonly FakeSessionSkillSeeder _seeder = new(); private readonly FakeSessionSkillRegistry _registry = new(); private readonly List _gitFixtures = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public InteractiveLaunchSpecServiceTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"cd_ilss_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _worktreeDir = Path.Combine(_tempDir, "worktree"); Directory.CreateDirectory(_worktreeDir); _claudeStubPath = Path.Combine(_tempDir, "claude.exe"); File.WriteAllText(_claudeStubPath, "stub"); } private GitRepoFixture CreateRepo() { var f = new GitRepoFixture(); _gitFixtures.Add(f); return f; } public void Dispose() { _db.Dispose(); foreach (var f in _gitFixtures) f.Dispose(); try { Directory.Delete(_tempDir, true); } catch { /* best effort */ } foreach (var d in _mergeHelperSessionDirs) try { Directory.Delete(d, true); } catch { /* best effort */ } } private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry { public List Installed { get; } = new(); public Task> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException(); public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); public Task> ListAsync(CancellationToken ct) => Task.FromResult>(Installed); } private InteractiveLaunchSpecService BuildService() => new(_db.CreateFactory(), _seeder, _registry, new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance), new WorkerConfig { ClaudeBin = _claudeStubPath }); private async Task SeedListAsync(string? workingDir = null, string name = "L") { var listId = Guid.NewGuid().ToString(); using var ctx = _db.CreateContext(); await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow, }); return listId; } private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T") { using var ctx = _db.CreateContext(); await new TaskRepository(ctx).AddAsync(new TaskEntity { Id = taskId, ListId = listId, Title = title, Status = status, CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, }); } private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null) { using var ctx = _db.CreateContext(); ctx.Worktrees.Add(new WorktreeEntity { TaskId = taskId, Path = path ?? _worktreeDir, BranchName = "claudedo/x", BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } private async Task SeedRunAsync(string taskId, string? sessionId) { using var ctx = _db.CreateContext(); await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", SessionId = sessionId, StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1), ExitCode = 0, ResultMarkdown = "ok", }); } [Fact] public async Task BuildForTaskAsync_TaskNotFound_ThrowsKeyNotFound() { var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(Guid.NewGuid().ToString(), CancellationToken.None)); } [Theory] [InlineData(TaskStatus.Running)] [InlineData(TaskStatus.Queued)] public async Task BuildForTaskAsync_TaskRunningOrQueued_ThrowsInvalidOperation(TaskStatus status) { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, status); await SeedWorktreeAsync(taskId, WorktreeState.Active); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ThrowsInvalidOperation() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: null); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_FreshTask_NoWorktreeButGitRepoConfigured_CreatesWorktreeOnDemand() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var listId = await SeedListAsync(workingDir: repo.RepoDir); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); // No worktree row seeded -- task has never run. var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); using var readCtx = _db.CreateContext(); var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId); Assert.NotNull(wtRow); Assert.Equal(WorktreeState.Active, wtRow!.State); Assert.True(Directory.Exists(wtRow.Path)); Assert.Equal(wtRow.Path, spec.Cwd); Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt Assert.Equal(_claudeStubPath, spec.Exe); } [Theory] [InlineData(WorktreeState.Merged)] [InlineData(WorktreeState.Discarded)] public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_ThrowsInvalidOperation(WorktreeState state) { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, state); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_WorktreeDirectoryMissing_ThrowsInvalidOperation() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active, path: Path.Combine(_tempDir, "does-not-exist")); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_ResumableTask_ProducesResumeArgsAndCwd() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); await SeedRunAsync(taskId, "sess-123"); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); } [Fact] public async Task BuildForTaskAsync_KeptWorktree_IsAllowed() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Kept); await SeedRunAsync(taskId, "sess-kept"); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args); } [Fact] public async Task BuildForTaskAsync_NeverRunTask_ProducesFreshStartSpec_NoResumeArg() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); // No TaskRunEntity at all -- never run. var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt Assert.Equal(_worktreeDir, spec.Cwd); } [Fact] public async Task BuildForTaskAsync_RunWithoutSessionId_ProducesFreshStartSpec() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); await SeedRunAsync(taskId, sessionId: null); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt } [Fact] public async Task BuildForTaskAsync_FreshTask_WithDescription_SeedsTitleAndDescriptionPrompt() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(taskId); t!.Description = "Do the thing"; await ctx.SaveChangesAsync(); } var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args); } [Fact] public async Task BuildForTaskAsync_SeedsSessionSkills_FilteredToInstalled() { _registry.Installed.Add(new SessionSkillEntity { Name = "installed-skill", SourceUrl = "https://example.com/x.git", PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d", AddedAt = DateTimeOffset.UtcNow, }); var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle, sessionSkillsJson: "[\"installed-skill\",\"missing-skill\"]"); await SeedWorktreeAsync(taskId, WorktreeState.Active); var svc = BuildService(); await svc.BuildForTaskAsync(taskId, CancellationToken.None); var call = Assert.Single(_seeder.Calls); Assert.Equal(_worktreeDir, call.WorkingDir); Assert.True(call.IsWorktree); Assert.Equal(new[] { "installed-skill" }, call.SkillNames); } [Fact] public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec() { var svc = BuildService(); var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None); Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); Assert.Empty(spec.Args); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Empty(_seeder.Calls); } [Fact] public async Task BuildForDirectoryAsync_NonExistentDirectory_ThrowsInvalidOperation() { var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); } // ── Merge helper ── private readonly List _mergeHelperSessionDirs = new(); /// The session dir is the value right after --add-dir; register it for cleanup /// (BuildForMergeHelperAsync writes under the real ~/.todo-app). private string TrackSessionDir(LaunchSpec spec) { var args = spec.Args.ToList(); var dir = args[args.IndexOf("--add-dir") + 1]; _mergeHelperSessionDirs.Add(dir); return dir; } [Fact] public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: _tempDir); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForMergeHelperAsync(Array.Empty(), listId, CancellationToken.None)); } [Fact] public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone")); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview); var svc = BuildService(); var ex = await Assert.ThrowsAsync( () => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None)); Assert.Contains("working directory", ex.Message); } [Fact] public async Task BuildForMergeHelperAsync_UnknownList_Throws() { var listId = await SeedListAsync(workingDir: _tempDir); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None)); } [Fact] public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo() { var repo = Path.Combine(_tempDir, "repoOnly"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var t1 = Guid.NewGuid().ToString(); var t2 = Guid.NewGuid().ToString(); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); var svc = BuildService(); var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None); var sessionDir = TrackSessionDir(spec); Assert.Equal(repo, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); var args = spec.Args.ToList(); var pmIdx = args.IndexOf("--permission-mode"); Assert.True(pmIdx >= 0); Assert.Equal("default", args[pmIdx + 1]); var atIdx = args.IndexOf("--allowedTools"); Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]); // --add-dir: session dir + the list's single repo dir var addIdx = args.IndexOf("--add-dir"); var appendIdx = args.IndexOf("--append-system-prompt-file"); var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1); Assert.Equal(new[] { sessionDir, repo }, addDirs); var systemPromptPath = args[appendIdx + 1]; Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath); Assert.True(File.Exists(systemPromptPath)); // kickoff is the LAST arg (positional), single line, points at brief.md var kickoff = args[^1]; var briefPath = Path.Combine(sessionDir, "brief.md"); Assert.Contains(briefPath, kickoff); Assert.DoesNotContain('\n', kickoff); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); } [Fact] public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask() { var repo = Path.Combine(_tempDir, "repoBrief"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var t1 = Guid.NewGuid().ToString(); var t2 = Guid.NewGuid().ToString(); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); var svc = BuildService(); var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None); var sessionDir = TrackSessionDir(spec); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); Assert.Contains("Scope: List: Alpha", brief); Assert.Contains($"Repo: {repo}", brief); Assert.Contains("First task", brief); Assert.Contains("Second task", brief); Assert.Contains(t1, brief); Assert.Contains(t2, brief); } [Fact] public void BuildPlanningStart_MapsPlanningArgsAndEnv() { var sessionDir = Path.Combine(_tempDir, "sess"); Directory.CreateDirectory(sessionDir); var ctx = new PlanningSessionStartContext( ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1", WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1", Files: new PlanningSessionFiles(sessionDir, Path.Combine(sessionDir, "system-prompt.md"), Path.Combine(sessionDir, "initial-prompt.txt"))); var spec = BuildService().BuildPlanningStart(ctx); Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); Assert.Contains("--permission-mode", spec.Args); // Default mode, not plan mode -- plan mode would gate the MCP planning tools. Assert.Contains("default", spec.Args); Assert.Equal("tok-1", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); Assert.Equal("20000", spec.Env["MAX_THINKING_TOKENS"]); } [Fact] public void BuildPlanningResume_MapsResumeArgsAndToken() { var ctx = new PlanningSessionResumeContext( ParentTaskId: "p1", WorkingDir: _worktreeDir, ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir); var spec = BuildService().BuildPlanningResume(ctx); Assert.Equal("--permission-mode", spec.Args[0]); Assert.Equal("default", spec.Args[1]); Assert.Equal("--allowedTools", spec.Args[2]); Assert.Contains("mcp__claudedo__", spec.Args[3]); Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray()); Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); Assert.Equal(_worktreeDir, spec.Cwd); } }