Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
T
mika kuns 9c4a63d2c5 fix(worker): thread handler task id into list-handler kickoff prompts
Neither the initial nor the handoff kickoff ever told a list-handler
session its own handler task id, so handoff_list_handler(taskId, ...)
was unrenderable -- the handoff chain broke exactly where it was needed
(#200/#201 on 2026-08-21).

Add {handlerTaskId} to both MergeHelperInitialDefault and
MergeHelperHandoffDefault, thread a handlerTaskId parameter through
BuildForMergeHelperAsync (interface, WorkerHub.GetMergeHelperLaunchSpec,
IWorkerClient/WorkerClient, and the MissionControlViewModel call site,
which already had the id from CreateMergeHelperTaskAsync but never
passed it on), and render it in BuildForMergeHelperHandoffAsync from the
taskId parameter it already receives.

RenderTemplate leaves unknown/missing tokens untouched, so a
user-edited override without the new token still renders fine -- no
forced migration for override users.
2026-08-21 18:00:16 +02:00

1241 lines
54 KiB
C#

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<GitRepoFixture> _gitFixtures = new();
private readonly List<string> _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<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,
});
_seededTaskIds.Add(taskId); // a fresh-task ConPTY spec may write a real brief.md under
// ~/.todo-app/task-sessions/<taskId> -- 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<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_OpensDirectlyInWorkingDir_NoWorktreeCreated()
{
var listId = await SeedListAsync(); // default WorkingDir (_tempDir) is not a git repo
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
using var readCtx = _db.CreateContext();
Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId));
}
[Fact]
public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_SeedsSessionSkillsWithIsWorktreeFalse()
{
_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\"]");
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(_tempDir, spec.Cwd);
var call = Assert.Single(_seeder.Calls);
Assert.Equal(_tempDir, call.WorkingDir);
Assert.False(call.IsWorktree);
Assert.Equal(new[] { "installed-skill" }, call.SkillNames);
}
[Fact]
public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ReopeningResumesItsOwnPriorSessionId()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec);
var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(_tempDir, secondSpec.Cwd);
Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec));
using var readCtx = _db.CreateContext();
Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId));
}
[Fact]
public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation()
{
var listId = await SeedListAsync();
using (var ctx = _db.CreateContext())
{
var list = await ctx.Lists.FindAsync(listId);
list!.WorkingDir = null; // distinct from "not a git repo" -- no directory at all
await ctx.SaveChangesAsync();
}
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(_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_GitRepoConfigured_CreatesFreshWorktree(WorktreeState state)
{
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);
await SeedWorktreeAsync(taskId, state);
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.Equal(wtRow.Path, spec.Cwd);
}
[Theory]
[InlineData(WorktreeState.Merged)]
[InlineData(WorktreeState.Discarded)]
public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_NotAGitRepo_OpensDirectlyInWorkingDir(WorktreeState state)
{
var listId = await SeedListAsync(); // default WorkingDir (_tempDir) is not a git repo
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
await SeedWorktreeAsync(taskId, state);
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(_tempDir, spec.Cwd);
using var readCtx = _db.CreateContext();
var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId);
Assert.NotNull(wtRow);
Assert.Equal(state, wtRow!.State); // the stale row is left untouched, not deleted or reused
}
[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(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<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();
}
/// A fresh (never-resumed) task with a title/description produces
/// `--add-dir <sessionDir> --effort <level> --session-id <guid> <kickoff>` -- the brief
/// travels via the file at <sessionDir>/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<string?> ReadInteractiveSessionIdAsync(string taskId)
{
using var ctx = _db.CreateContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId);
return task!.InteractiveSessionId;
}
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, Guid.NewGuid().ToString(), 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, Guid.NewGuid().ToString(), 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", Guid.NewGuid().ToString(), 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, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
var args = spec.Args.ToList();
var modelIdx = args.IndexOf("--model");
Assert.True(modelIdx >= 0);
Assert.Equal(ModelRegistry.HandlerTriageAlias, args[modelIdx + 1]);
var effortIdx = args.IndexOf("--effort");
Assert.True(effortIdx >= 0);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.HandlerTriageAlias).Effort, args[effortIdx + 1]);
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,Task", 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, Guid.NewGuid().ToString(), 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, Guid.NewGuid().ToString(), 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);
}
// Regression guard: no session ever learned its own handler task id, so it could never call
// handoff_list_handler(taskId, ...) on itself -- the handoff chain broke exactly where it was
// needed. The brief must carry it.
[Fact]
public async Task BuildForMergeHelperAsync_BriefIncludesHandlerTaskId()
{
var repo = Path.Combine(_tempDir, "repoHandlerId");
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");
var handlerTaskId = Guid.NewGuid().ToString();
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
Assert.Contains(handlerTaskId, 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, Guid.NewGuid().ToString(), 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, Guid.NewGuid().ToString(), 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, Guid.NewGuid().ToString(), 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);
}
// ── 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<InvalidOperationException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, Array.Empty<string>(), "wait", CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_UnknownHandlerTask_ThrowsKeyNotFound()
{
await Assert.ThrowsAsync<KeyNotFoundException>(
() => BuildService().BuildForMergeHelperHandoffAsync("no-such-task", new[] { "x" }, "wait", 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<KeyNotFoundException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { "no-such-task" }, "wait", 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<InvalidOperationException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", 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 }, "wait", 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('/'));
}
// Regression guard: the handoff session must see the SAME handler task id as the run that
// called handoff_list_handler -- it's the id already passed in as taskId, just never rendered
// into the handoff kickoff.
[Fact]
public async Task BuildForMergeHelperHandoffAsync_HandoffIncludesSameHandlerTaskId()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview);
var svc = BuildService();
var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var handoff = File.ReadAllText(Path.Combine(sessionDir, "handoff.md"));
Assert.Contains(handlerTaskId, handoff);
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_UnknownPhase_Throws()
{
var listId = await SeedListAsync(workingDir: _tempDir);
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<ArgumentException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "phase-9", CancellationToken.None));
Assert.Contains("phase-9", ex.Message);
}
[Theory]
[InlineData("wait", "phase 3", false)]
[InlineData("wait_final", "phase 3", true)]
[InlineData("merge", "phase 4", false)]
[InlineData("merge_final", "phase 4", true)]
public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_PerPhaseModelEffortPromptAndKickoff(
string nextPhase, string expectedPhaseLabel, bool expectFinalNote)
{
var repo = Path.Combine(_tempDir, $"repoHandoff-{nextPhase}");
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 }, nextPhase, 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 expectedModel = nextPhase is "wait" or "wait_final" ? ModelRegistry.HandlerWaitAlias : ModelRegistry.HandlerMergeAlias;
var expectedPromptKind = nextPhase is "wait" or "wait_final" ? PromptKind.MergeHelperWait : PromptKind.MergeHelperMerge;
var args = spec.Args.ToList();
var modelIdx = args.IndexOf("--model");
Assert.Equal(expectedModel, args[modelIdx + 1]);
var effortIdx = args.IndexOf("--effort");
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, expectedModel).Effort, args[effortIdx + 1]);
var pmIdx = args.IndexOf("--permission-mode");
Assert.Equal("auto", args[pmIdx + 1]);
var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task", 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);
// The handoff session only ever gets the prompt for the role it is about to run --
// never the Triage prompt the first session ran with, and never the OTHER role's prompt.
Assert.Equal(PromptFiles.ReadOrDefault(expectedPromptKind), File.ReadAllText(systemPromptPath));
var kickoff = args[^1];
var handoffPath = Path.Combine(sessionDir, "handoff.md");
Assert.Contains(handoffPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Contains(expectedPhaseLabel, 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);
if (expectFinalNote)
{
Assert.Contains("FINAL round", handoffBrief);
Assert.Contains("rerun", handoffBrief, StringComparison.OrdinalIgnoreCase);
}
else
{
Assert.DoesNotContain("FINAL round", handoffBrief);
}
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
private async Task<int> 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 both merge-helper builders wrote the SAME system prompt,
// so the handoff session received the phase 0-2 (dedupe/enhance) instructions and was told to
// ignore them by its brief alone. Each session must carry only its own phases.
[Fact]
public async Task MergeHelperBuilders_WriteDifferentSystemPrompts_EachScopedToItsOwnPhases()
{
var repo = Path.Combine(_tempDir, "repoPromptSplit");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
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 svc = BuildService();
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
var triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec);
var triagePrompt = File.ReadAllText(Path.Combine(triageDir, "system-prompt.md"));
var executePrompt = File.ReadAllText(Path.Combine(executeDir, "system-prompt.md"));
Assert.NotEqual(triagePrompt, executePrompt);
Assert.Contains("## Phase 1", triagePrompt);
Assert.DoesNotContain("## Phase 4", triagePrompt);
Assert.Contains("## Phase 4", executePrompt);
Assert.DoesNotContain("## Phase 1", executePrompt);
}
// 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, handlerTaskId, CancellationToken.None);
TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
TrackSessionDir(handoffSpec);
var specs = new List<LaunchSpec>
{
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"]));
}
}