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.Runner; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Runner; /// Verifies InteractiveLaunchSpecService's guards /// (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 readonly List _seededTaskIds = 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 */ } foreach (var id in _seededTaskIds) try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), 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 GitService(), 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", string? description = null) { 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, Description = description, }); _seededTaskIds.Add(taskId); // a fresh-task ConPTY spec may write a real brief.md under // ~/.todo-app/task-sessions/ -- clean it up on dispose } 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(_claudeStubPath, spec.Exe); var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec); Assert.Contains(briefPath, kickoff); // fresh session points claude at the brief file, not a raw prompt Assert.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title } [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" }, ArgsAfterEffort(spec)); Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, 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" }, ArgsAfterEffort(spec)); } [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); var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title Assert.Contains(briefPath, kickoff); Assert.Equal("T", File.ReadAllText(briefPath)); Assert.Equal(_worktreeDir, spec.Cwd); // The generated session id is persisted BEFORE this call returns, so a closed/aborted // session still leaves a resumable id. Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId)); } [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); var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title Assert.Contains(briefPath, kickoff); Assert.Equal("T", File.ReadAllText(briefPath)); Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId)); } [Fact] public async Task BuildForTaskAsync_ExistingInteractiveSessionId_TakesPrecedenceOverRunSessionId() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); await SeedRunAsync(taskId, "autonomous-run-session"); using (var ctx = _db.CreateContext()) { await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "interactive-session-1"); } var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "--resume", "interactive-session-1" }, ArgsAfterEffort(spec)); } [Fact] public async Task BuildForTaskAsync_ReopeningInteractiveSession_ResumesItsOwnPriorSessionId() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); // No autonomous run at all -- only a prior interactive session for this task. var svc = BuildService(); var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec); // Reopen (e.g. the pane was closed/aborted) -- must resume the SAME session id, not // start a new conversation. var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec)); } [Fact] public async Task BuildForTaskAsync_FreshlyRecreatedWorktree_IgnoresAndOverwritesStaleInteractiveSessionId() { 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 -- BuildForTaskAsync will create one fresh. using (var ctx = _db.CreateContext()) { await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "stale-interactive-session"); } var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); // Fresh-start, not a resume of the stale id. var (_, _, _, newSessionId) = ParseFreshTaskArgs(spec); Assert.NotEqual("stale-interactive-session", newSessionId); Assert.Equal(newSessionId, await ReadInteractiveSessionIdAsync(taskId)); } [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); var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec); Assert.Contains(briefPath, kickoff); Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath)); } [Fact] public async Task BuildForTaskAsync_FreshTask_DashInDescription_DoesNotAppearInArgs() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle, title: "Worker: OAuth-Usage-Client"); await SeedWorktreeAsync(taskId, WorktreeState.Active); var description = "WorkerHub.PreviewMerge(taskId, targetBranch) -> MergePreviewDto\n" + "run with --abort -Seconds 5 -m -A and `backticks` \"quotes\""; using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(taskId); t!.Description = description; await ctx.SaveChangesAsync(); } var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec); Assert.Contains(briefPath, kickoff); Assert.DoesNotContain('\n', kickoff); // No arg element carries any fragment of the title/description -- it only ever // reaches the session via the brief file. foreach (var arg in spec.Args) { Assert.DoesNotContain("->", arg); Assert.DoesNotContain("--abort", arg); Assert.DoesNotContain("OAuth-Usage-Client", arg); } var briefContent = File.ReadAllText(briefPath); Assert.Equal($"Worker: OAuth-Usage-Client\n\n{description}", briefContent); } [Fact] public async Task BuildForTaskAsync_FreshTask_NoTitleNoDescription_NoPositionalArg() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle, title: ""); await SeedWorktreeAsync(taskId, WorktreeState.Active); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); // no brief -> no --add-dir, no positional arg -- but a session id is still pre-assigned var rest = ArgsAfterEffort(spec); Assert.Equal("--session-id", rest[0]); Assert.Equal(rest[1], await ReadInteractiveSessionIdAsync(taskId)); } [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(ArgsAfterEffort(spec)); Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, 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 ── /// Every ConPTY spec now leads with `--effort ` from the per-model preset (the seeded /// settings row has no overrides, so the shipped default for the default model applies). /// Asserts that pair and returns the rest of the args for the test's own comparison. private static string[] ArgsAfterEffort(LaunchSpec spec) { var args = spec.Args.ToList(); Assert.Equal("--effort", args[0]); Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]); return args.Skip(2).ToArray(); } /// A fresh (never-resumed) task with a title/description produces /// `--add-dir --effort --session-id ` -- the brief /// travels via the file at /brief.md, never as a raw CLI argument. Asserts that /// exact shape and returns (sessionDir, briefPath, kickoff, sessionId) for the test's own /// checks. Cleanup happens via `_seededTaskIds` in Dispose (the session dir is keyed by task id). private static (string SessionDir, string BriefPath, string Kickoff, string SessionId) ParseFreshTaskArgs(LaunchSpec spec) { var args = spec.Args.ToList(); Assert.Equal(7, args.Count); Assert.Equal("--add-dir", args[0]); var sessionDir = args[1]; Assert.Equal("--effort", args[2]); Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[3]); Assert.Equal("--session-id", args[4]); var sessionId = args[5]; var kickoff = args[6]; var briefPath = Path.Combine(sessionDir, "brief.md"); return (sessionDir, briefPath, kickoff, sessionId); } private async Task ReadInteractiveSessionIdAsync(string taskId) { using var ctx = _db.CreateContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId); return task!.InteractiveSessionId; } 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("auto", 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(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]); } // A directory argument that keeps its trailing separator escapes its own closing quote once the // ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal // quote), so --add-dir's variadic list swallows every following argument -- including the // positional kickoff, leaving the session with no prompt at all. [Fact] public async Task BuildForMergeHelperAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator() { var repo = Path.Combine(_tempDir, "repoTrailingSep"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing"); var t1 = Guid.NewGuid().ToString(); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); var svc = BuildService(); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None); var sessionDir = TrackSessionDir(spec); var args = spec.Args.ToList(); var addIdx = args.IndexOf("--add-dir"); var appendIdx = args.IndexOf("--append-system-prompt-file"); Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1)); Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/')); } [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 async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription() { var repo = Path.Combine(_tempDir, "repoDesc"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var t1 = Guid.NewGuid().ToString(); await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task", description: "Do the thing carefully and report back."); var svc = BuildService(); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None); var sessionDir = TrackSessionDir(spec); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); Assert.Contains("Do the thing carefully and report back.", brief); } [Fact] public async Task BuildForMergeHelperAsync_TaskWithoutDescription_RendersCleanly() { var repo = Path.Combine(_tempDir, "repoNoDesc"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var t1 = Guid.NewGuid().ToString(); await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null); var svc = BuildService(); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None); var sessionDir = TrackSessionDir(spec); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var line = brief.Split('\n').Single(l => l.Contains("No description task")); Assert.Equal($"- [Idle] No description task (id: {t1})", line.TrimEnd('\r')); } [Fact] public async Task BuildForMergeHelperAsync_BriefDescriptionWithCodeFenceAndHeadingsKeepsTaskListRecognizable() { var repo = Path.Combine(_tempDir, "repoMd"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var t1 = Guid.NewGuid().ToString(); var t2 = Guid.NewGuid().ToString(); var trickyDescription = """ # Heading inside description - a nested list item - another one ```csharp var x = "fenced code block"; ``` """; await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task", description: trickyDescription); await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description"); 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")); var lines = brief.Replace("\r\n", "\n").Split('\n'); // The two task bullets must still be recognizable top-level list items, unbroken // by the embedded heading/list/code-fence in the first task's description. Assert.Contains(lines, l => l == $"- [Idle] First task (id: {t1})"); Assert.Contains(lines, l => l == $"- [WaitingForReview] Second task (id: {t2})"); Assert.Contains("Heading inside description", brief); Assert.Contains("fenced code block", brief); Assert.Contains("var x = \"fenced code block\";", brief); } // ── CreateMergeHelperTaskAsync ── [Fact] public async Task CreateMergeHelperTaskAsync_EmptyTaskIds_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: _tempDir); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.CreateMergeHelperTaskAsync(Array.Empty(), listId, "title", "header", CancellationToken.None)); } [Fact] public async Task CreateMergeHelperTaskAsync_UnknownList_ThrowsKeyNotFound() { var taskId = Guid.NewGuid().ToString(); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.CreateMergeHelperTaskAsync(new[] { taskId }, "no-such-list", "title", "header", CancellationToken.None)); } [Fact] public async Task CreateMergeHelperTaskAsync_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(); await Assert.ThrowsAsync( () => svc.CreateMergeHelperTaskAsync(new[] { taskId }, listId, "title", "header", CancellationToken.None)); } [Fact] public async Task CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha"); var t1 = Guid.NewGuid().ToString(); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); var svc = BuildService(); var newTaskId = await svc.CreateMergeHelperTaskAsync( new[] { t1 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None); using var readCtx = _db.CreateContext(); var created = await new TaskRepository(readCtx).GetByIdAsync(newTaskId); Assert.NotNull(created); Assert.Equal("List handler: Alpha", created!.Title); Assert.Equal(TaskStatus.Idle, created.Status); Assert.True(created.IsManual); Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit); Assert.Null(created.HandlerHeadCommit); Assert.Contains("Tasks handled by this run:", created.Description); Assert.Contains("First task", created.Description); Assert.Contains(t1, created.Description); } // ── BuildForMergeHelperHandoffAsync ── [Fact] public async Task BuildForMergeHelperHandoffAsync_EmptySurvivingTaskIds_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: _tempDir); var handlerTaskId = Guid.NewGuid().ToString(); await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, Array.Empty(), CancellationToken.None)); } [Fact] public async Task BuildForMergeHelperHandoffAsync_UnknownHandlerTask_ThrowsKeyNotFound() { await Assert.ThrowsAsync( () => BuildService().BuildForMergeHelperHandoffAsync("no-such-task", new[] { "x" }, CancellationToken.None)); } [Fact] public async Task BuildForMergeHelperHandoffAsync_UnknownSurvivingTask_ThrowsKeyNotFound() { var listId = await SeedListAsync(workingDir: _tempDir); var handlerTaskId = Guid.NewGuid().ToString(); await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { "no-such-task" }, CancellationToken.None)); } [Fact] public async Task BuildForMergeHelperHandoffAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone")); var handlerTaskId = Guid.NewGuid().ToString(); await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle); var survivor = Guid.NewGuid().ToString(); await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview); var svc = BuildService(); var ex = await Assert.ThrowsAsync( () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None)); Assert.Contains("working directory", ex.Message); } [Fact] public async Task BuildForMergeHelperHandoffAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator() { var repo = Path.Combine(_tempDir, "repoHandoffTrailingSep"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing"); var handlerTaskId = Guid.NewGuid().ToString(); await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Trailing"); var survivor = Guid.NewGuid().ToString(); await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor"); var svc = BuildService(); var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None); var sessionDir = TrackSessionDir(spec); var args = spec.Args.ToList(); var addIdx = args.IndexOf("--add-dir"); var appendIdx = args.IndexOf("--append-system-prompt-file"); Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1)); Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/')); } [Fact] public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_NoNewTaskCreated() { var repo = Path.Combine(_tempDir, "repoHandoff"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var handlerTaskId = Guid.NewGuid().ToString(); await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Alpha"); var survivor = Guid.NewGuid().ToString(); await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor task"); var beforeCount = await CountTasksAsync(); var svc = BuildService(); var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None); var sessionDir = TrackSessionDir(spec); var afterCount = await CountTasksAsync(); Assert.Equal(beforeCount, afterCount); Assert.Equal(repo, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); var args = spec.Args.ToList(); var atIdx = args.IndexOf("--allowedTools"); Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]); var appendIdx = args.IndexOf("--append-system-prompt-file"); var systemPromptPath = args[appendIdx + 1]; Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath); Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelper), File.ReadAllText(systemPromptPath)); var kickoff = args[^1]; var handoffPath = Path.Combine(sessionDir, "handoff.md"); Assert.Contains(handoffPath, kickoff); Assert.DoesNotContain('\n', kickoff); var handoffBrief = File.ReadAllText(handoffPath); Assert.Contains("Scope: List: Alpha", handoffBrief); Assert.Contains($"Repo: {repo}", handoffBrief); Assert.Contains("Survivor task", handoffBrief); Assert.Contains(survivor, handoffBrief); Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase); Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]); } private async Task CountTasksAsync() { using var ctx = _db.CreateContext(); return await ctx.Tasks.CountAsync(); } [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); var args = ArgsAfterEffort(spec); Assert.Equal("--permission-mode", args[0]); Assert.Equal("default", args[1]); Assert.Equal("--allowedTools", args[2]); Assert.Contains("mcp__claudedo__", args[3]); Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray()); Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); Assert.Equal(_worktreeDir, spec.Cwd); } // Regression guard for the bug where BuildForMergeHelperHandoffAsync set MCP_TOOL_TIMEOUT to // an older, shorter value (200000) than every other ConPTY spec (930000) after a parallel // merge landed the two changes independently. Every spec this service builds must carry the // SAME value, sourced from the one constant, so the value can never drift again. [Fact] public async Task AllBuiltSpecs_CarryTheSameMcpToolTimeout() { var repo = Path.Combine(_tempDir, "repoTimeoutCheck"); Directory.CreateDirectory(repo); var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); await SeedRunAsync(taskId, "sess-timeout-check"); var handlerTaskId = Guid.NewGuid().ToString(); await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler"); var survivor = Guid.NewGuid().ToString(); await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor"); var sessionDir = Path.Combine(_tempDir, "sess-timeout-check"); Directory.CreateDirectory(sessionDir); var planningStartCtx = 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 planningResumeCtx = new PlanningSessionResumeContext( ParentTaskId: "p1", WorkingDir: _worktreeDir, ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir); var svc = BuildService(); var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, CancellationToken.None); TrackSessionDir(mergeHelperSpec); var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None); TrackSessionDir(handoffSpec); var specs = new List { await svc.BuildForTaskAsync(taskId, CancellationToken.None), await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None), mergeHelperSpec, handoffSpec, svc.BuildPlanningStart(planningStartCtx), svc.BuildPlanningResume(planningResumeCtx), }; Assert.All(specs, spec => Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"])); } }