Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
T
mika kuns b38b0857dd feat(worker): render task descriptions into the list-handler brief
Phase 0 forced a batch_get_tasks full-fetch across every task just to see
descriptions, which blew past the client's token limit on larger lists.
brief.md lives on disk and has no such limit, so descriptions now render
there directly (fenced with an extended backtick run, indented under the
list bullet, so embedded headings/lists/code fences can't break the task
list's own structure). Phase 0 now treats the brief as the primary source
and only falls back to batch_get_tasks for fields it doesn't carry.
2026-08-05 10:48:37 +02:00

658 lines
27 KiB
C#

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
/// (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<GitRepoFixture> _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<SessionSkillEntity> Installed { get; } = new();
public Task<IReadOnlyList<string>> 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<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
}
private InteractiveLaunchSpecService BuildService() =>
new(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(),
new WorkerConfig { ClaudeBin = _claudeStubPath });
private async Task<string> 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,
});
}
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<KeyNotFoundException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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" }, ArgsAfterEffort(spec)); // 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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("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" }, 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);
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // 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" }, ArgsAfterEffort(spec)); // 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" }, ArgsAfterEffort(spec));
}
[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("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Empty(_seeder.Calls);
}
[Fact]
public async Task BuildForDirectoryAsync_NonExistentDirectory_ThrowsInvalidOperation()
{
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
}
// ── Merge helper ──
/// Every ConPTY spec now leads with `--effort <level>` 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();
}
private readonly List<string> _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<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), 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<InvalidOperationException>(
() => 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<KeyNotFoundException>(
() => 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("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 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<InvalidOperationException>(
() => svc.CreateMergeHelperTaskAsync(Array.Empty<string>(), listId, "title", "header", CancellationToken.None));
}
[Fact]
public async Task CreateMergeHelperTaskAsync_UnknownList_ThrowsKeyNotFound()
{
var taskId = Guid.NewGuid().ToString();
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => 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<InvalidOperationException>(
() => 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);
}
[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);
}
}