Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs
T
mika kuns ef285b21fd feat(planning): run interactive planning sessions via embedded ConPTY
Planning start/resume opened an external Windows Terminal (wt) window.
Route them through the embedded ConPTY Command Center pane instead, matching
the existing interactive-session UX (no external window).

- Extract bare planning arg builders (BuildPlanningStartArgs/ResumeArgs) from
  WindowsTerminalLauncher; the wt path still uses them (kept, not removed).
- InteractiveLaunchSpecService.BuildPlanningStart/Resume map a planning
  context into a LaunchSpec (planning args + env: MAX_THINKING_TOKENS,
  CLAUDEDO_PLANNING_TOKEN). Hub GetPlanningStart/ResumeLaunchSpec run
  StartAsync/ResumeAsync then return the spec.
- UI: OpenPlanningSession + the resume branch raise OpenPlanningConPtyRequested;
  the shell opens Mission Control and hosts a planning ConPTY pane.

Env is process-global by design (sequential human-paced sessions). wt planning
code retained. Tests added for the arg/env mapping.
2026-07-24 12:12:23 +02:00

121 lines
5.1 KiB
C#

using ClaudeDo.Worker.Planning;
namespace ClaudeDo.Worker.Tests.Planning;
public sealed class WindowsTerminalLauncherTests
{
private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
{
var workingDir = wd ?? Path.GetTempPath();
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(dir);
return new PlanningSessionStartContext(
ParentTaskId: "task-1",
WorkingDir: workingDir,
Token: "test-token",
WorktreePath: workingDir,
BranchName: "claudedo/planning/task1",
Files: new PlanningSessionFiles(
SessionDirectory: dir,
SystemPromptPath: Path.Combine(dir, "system-prompt.md"),
InitialPromptPath: Path.Combine(dir, "initial-prompt.txt")));
}
[Fact]
public async Task LaunchPlanningStartAsync_WorkingDirMissing_Throws()
{
var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
Assert.Contains("Working directory", ex.Message);
}
[Fact]
public async Task LaunchPlanningStartAsync_WtMissing_Throws()
{
var ctx = MakeStartCtx();
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
var sut = new WindowsTerminalLauncher(
wtPath: "C:/no/such/wt.exe",
claudePath: "claude");
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
Assert.Contains("Windows Terminal", ex.Message);
}
[Fact]
public void BuildPlanningStartCommand_SeedsViaBriefFile_NotInlineText()
{
var ctx = MakeStartCtx();
var command = WindowsTerminalLauncher.BuildPlanningStartCommand("claude.exe", ctx);
// The brief is read from a file: the kickoff references the brief path, and its
// directory is exposed via --add-dir so the Read tool can open it.
Assert.Contains(ctx.Files.InitialPromptPath, command);
Assert.Contains($"--add-dir' '{ctx.Files.SessionDirectory}", command);
// The positional kickoff must follow the single-value --append-system-prompt-file
// flag, or a preceding variadic flag would swallow it.
var appendIdx = command.IndexOf("--append-system-prompt-file", StringComparison.Ordinal);
var addDirIdx = command.IndexOf("--add-dir", StringComparison.Ordinal);
Assert.True(appendIdx > addDirIdx, "--append-system-prompt-file must come after --add-dir");
Assert.True(command.IndexOf(ctx.Files.InitialPromptPath, StringComparison.Ordinal) > appendIdx,
"the kickoff prompt must be the last (positional) token");
// The legacy env-var indirection is gone.
Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command);
}
[Fact]
public void BuildPlanningStartArgs_HasPlanningFlagsAndKickoffLast()
{
var ctx = MakeStartCtx();
var args = WindowsTerminalLauncher.BuildPlanningStartArgs(ctx);
Assert.Equal("--model", args[0]);
var permIdx = args.ToList().IndexOf("--permission-mode");
Assert.True(permIdx >= 0);
Assert.Equal("plan", args[permIdx + 1]);
Assert.Contains("--allowedTools", args);
Assert.Contains(ctx.Files.SessionDirectory, args);
Assert.Contains(ctx.Files.SystemPromptPath, args);
// Kickoff positional is the last token and points at the brief file.
Assert.Contains(ctx.Files.InitialPromptPath, args[^1]);
}
[Fact]
public void BuildPlanningResumeArgs_PinsPlanModeAndResume()
{
var args = WindowsTerminalLauncher.BuildPlanningResumeArgs("sess-9");
Assert.Equal(new[] { "--permission-mode", "plan", "--resume", "sess-9" }, args);
}
[Fact]
public void BuildResumeCommand_ResumesSessionSingleQuoted()
{
var command = WindowsTerminalLauncher.BuildResumeCommand("claude.exe", "sess-42");
Assert.Contains("--resume", command);
// The session id is single-quoted so ';' and friends pass through untouched.
Assert.Contains("'--resume' 'sess-42'", command);
// A plain resume drives the real interactive TUI — no planning-only flags.
Assert.DoesNotContain("--permission-mode", command);
Assert.DoesNotContain("--append-system-prompt-file", command);
}
[Fact]
public async Task LaunchResumeAsync_WorkingDirMissing_Throws()
{
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
var missing = Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid());
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
sut.LaunchResumeAsync(missing, "sess-1", CancellationToken.None));
Assert.Contains("Working directory", ex.Message);
}
}