ClaudeDo never passed --effort, so every session inherited whatever effortLevel the user's Claude Code config happened to carry. Settings -> General now holds one row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25) supplying the global effort and turn defaults; list- and task-level max-turns overrides still win, and the agent editor's inherited badge follows the model. --effort is applied to autonomous runs and to every ConPTY spec (task session, planning start/resume, ad-hoc, list handler). The model itself is deliberately not forced on interactive sessions. The single global 'Max turns' field is replaced by the table, and 'fable' joins ModelRegistry.Aliases. The migration also adds the is_manual columns used by the next commit.
522 lines
21 KiB
C#
522 lines
21 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 mirror WorkerHub.ResumeTaskInTerminal
|
|
/// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the
|
|
/// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and
|
|
/// produces --resume for a resumable task vs. a fresh-start spec for a never-run task.
|
|
/// Never spawns the real claude CLI: ClaudeBin points at a stub file on disk.
|
|
public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly string _tempDir;
|
|
private readonly string _worktreeDir;
|
|
private readonly string _claudeStubPath;
|
|
private readonly FakeSessionSkillSeeder _seeder = new();
|
|
private readonly FakeSessionSkillRegistry _registry = new();
|
|
private readonly List<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 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")
|
|
{
|
|
using var ctx = _db.CreateContext();
|
|
await new TaskRepository(ctx).AddAsync(new TaskEntity
|
|
{
|
|
Id = taskId, ListId = listId, Title = title, Status = status,
|
|
CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson,
|
|
});
|
|
}
|
|
|
|
private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null)
|
|
{
|
|
using var ctx = _db.CreateContext();
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = taskId, Path = path ?? _worktreeDir, BranchName = "claudedo/x",
|
|
BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedRunAsync(string taskId, string? sessionId)
|
|
{
|
|
using var ctx = _db.CreateContext();
|
|
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
|
|
Prompt = "p", SessionId = sessionId,
|
|
StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1),
|
|
ExitCode = 0, ResultMarkdown = "ok",
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildForTaskAsync_TaskNotFound_ThrowsKeyNotFound()
|
|
{
|
|
var svc = BuildService();
|
|
await Assert.ThrowsAsync<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("default", args[pmIdx + 1]);
|
|
|
|
var atIdx = args.IndexOf("--allowedTools");
|
|
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
|
|
|
|
// --add-dir: session dir + the list's single repo dir
|
|
var addIdx = args.IndexOf("--add-dir");
|
|
var appendIdx = args.IndexOf("--append-system-prompt-file");
|
|
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
|
|
Assert.Equal(new[] { sessionDir, repo }, addDirs);
|
|
|
|
var systemPromptPath = args[appendIdx + 1];
|
|
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
|
|
Assert.True(File.Exists(systemPromptPath));
|
|
|
|
// kickoff is the LAST arg (positional), single line, points at brief.md
|
|
var kickoff = args[^1];
|
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
|
Assert.Contains(briefPath, kickoff);
|
|
Assert.DoesNotContain('\n', kickoff);
|
|
|
|
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
|
|
{
|
|
var repo = Path.Combine(_tempDir, "repoBrief");
|
|
Directory.CreateDirectory(repo);
|
|
|
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
|
var t1 = Guid.NewGuid().ToString();
|
|
var t2 = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
|
|
|
var svc = BuildService();
|
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
|
var sessionDir = TrackSessionDir(spec);
|
|
|
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
|
Assert.Contains("Scope: List: Alpha", brief);
|
|
Assert.Contains($"Repo: {repo}", brief);
|
|
Assert.Contains("First task", brief);
|
|
Assert.Contains("Second task", brief);
|
|
Assert.Contains(t1, brief);
|
|
Assert.Contains(t2, brief);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
|
|
{
|
|
var sessionDir = Path.Combine(_tempDir, "sess");
|
|
Directory.CreateDirectory(sessionDir);
|
|
var ctx = new PlanningSessionStartContext(
|
|
ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
|
|
WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
|
|
Files: new PlanningSessionFiles(sessionDir,
|
|
Path.Combine(sessionDir, "system-prompt.md"),
|
|
Path.Combine(sessionDir, "initial-prompt.txt")));
|
|
|
|
var spec = BuildService().BuildPlanningStart(ctx);
|
|
|
|
Assert.Equal(_worktreeDir, spec.Cwd);
|
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
|
Assert.Contains("--permission-mode", spec.Args);
|
|
// Default mode, not plan mode -- plan mode would gate the MCP planning tools.
|
|
Assert.Contains("default", spec.Args);
|
|
Assert.Equal("tok-1", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
|
|
Assert.Equal("20000", spec.Env["MAX_THINKING_TOKENS"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPlanningResume_MapsResumeArgsAndToken()
|
|
{
|
|
var ctx = new PlanningSessionResumeContext(
|
|
ParentTaskId: "p1", WorkingDir: _worktreeDir,
|
|
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
|
|
|
|
var spec = BuildService().BuildPlanningResume(ctx);
|
|
|
|
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);
|
|
}
|
|
}
|