From ea16da275624e9df78d2365d2f979b88911effc6 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 24 Jun 2026 13:19:03 +0200 Subject: [PATCH] fix(worker): keep interactive & planning prompts intact past Windows Terminal wt.exe treats ';' as a command/tab delimiter in every argument, with no escape that survives quoting (microsoft/terminal#13264), so a task description containing ';' spawned extra terminals on "Run interactively" and planning start. Route the launch as wt -> powershell -> claude and pass the free-text prompt via $env:CLAUDEDO_LAUNCH_PROMPT so it never reaches the wt command line; PowerShell binds the variable as a single argument (embedded quotes escaped for PS 5.1). Also clarify the launcher, which serves interactive runs too (not just planning): IPlanningTerminalLauncher -> ITerminalLauncher, WindowsTerminalPlanningLauncher -> WindowsTerminalLauncher, LaunchStart/Resume -> LaunchPlanning{Start,Resume}Async. --- src/ClaudeDo.Worker/CLAUDE.md | 2 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 10 +- .../Interfaces/IPlanningTerminalLauncher.cs | 13 -- .../Planning/Interfaces/ITerminalLauncher.cs | 16 ++ .../Planning/WindowsTerminalLauncher.cs | 192 ++++++++++++++++++ .../WindowsTerminalPlanningLauncher.cs | 168 --------------- src/ClaudeDo.Worker/Program.cs | 4 +- .../Hub/PlanningHubTests.cs | 16 +- ...sts.cs => WindowsTerminalLauncherTests.cs} | 18 +- 9 files changed, 233 insertions(+), 206 deletions(-) delete mode 100644 src/ClaudeDo.Worker/Planning/Interfaces/IPlanningTerminalLauncher.cs create mode 100644 src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs create mode 100644 src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs delete mode 100644 src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs rename tests/ClaudeDo.Worker.Tests/Planning/{WindowsTerminalPlanningLauncherTests.cs => WindowsTerminalLauncherTests.cs} (69%) diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 36c2822..e9ba598 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -12,7 +12,7 @@ Worker/ Worktrees/ — WorktreeMaintenanceService Agents/ — AgentFileService, DefaultAgentSeeder Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution) - Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalPlanningLauncher (IPlanningTerminalLauncher) + Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning + interactive sessions Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished) External/ — ExternalMcpService + sibling tool classes Config/ — WorkerConfig diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 13720df..6f897c7 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -102,7 +102,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly TaskResetService _resetService; private readonly TaskMergeService _mergeService; private readonly PlanningSessionManager _planning; - private readonly IPlanningTerminalLauncher _launcher; + private readonly ITerminalLauncher _launcher; private readonly PlanningAggregator _planningAggregator; private readonly PlanningMergeOrchestrator _planningMergeOrchestrator; private readonly PlanningChainCoordinator _planningChain; @@ -127,7 +127,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub TaskResetService resetService, TaskMergeService mergeService, PlanningSessionManager planning, - IPlanningTerminalLauncher launcher, + ITerminalLauncher launcher, PlanningAggregator planningAggregator, PlanningMergeOrchestrator planningMergeOrchestrator, PlanningChainCoordinator planningChain, @@ -533,9 +533,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted); try { - await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted); + await _launcher.LaunchPlanningStartAsync(ctx, Context.ConnectionAborted); } - catch (PlanningLaunchException) + catch (TerminalLaunchException) { // Launch failed before any children could be created; force-cleanup is safe. await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted); @@ -548,7 +548,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public async Task ResumePlanningSessionAsync(string taskId) { var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted); - await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted); + await _launcher.LaunchPlanningResumeAsync(ctx, Context.ConnectionAborted); return ctx; } diff --git a/src/ClaudeDo.Worker/Planning/Interfaces/IPlanningTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/Interfaces/IPlanningTerminalLauncher.cs deleted file mode 100644 index 3b0d83c..0000000 --- a/src/ClaudeDo.Worker/Planning/Interfaces/IPlanningTerminalLauncher.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ClaudeDo.Worker.Planning; - -public interface IPlanningTerminalLauncher -{ - Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); - Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); - Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken); -} - -public sealed class PlanningLaunchException : Exception -{ - public PlanningLaunchException(string message) : base(message) { } -} diff --git a/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs new file mode 100644 index 0000000..2700e07 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs @@ -0,0 +1,16 @@ +namespace ClaudeDo.Worker.Planning; + +// Launches the Claude CLI in a visible terminal for human-driven sessions: +// planning (start/resume) and the ad-hoc "Run interactively" action. Not used for +// headless task execution (that path is ClaudeProcess, prompt over stdin). +public interface ITerminalLauncher +{ + Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); + Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); + Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken); +} + +public sealed class TerminalLaunchException : Exception +{ + public TerminalLaunchException(string message) : base(message) { } +} diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs new file mode 100644 index 0000000..b8b4e4f --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs @@ -0,0 +1,192 @@ +// Claude CLI flags (verified 2026-04-23 via Context7): +// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists) +// Allowed-tools: --allowedTools (camelCase), comma-separated tokens +// System prompt: --append-system-prompt-file (file form) +// Session ID: no pre-assign flag; resume with --resume +// Launch model: wt.exe -> powershell -> claude.exe (UseShellExecute=false). +// wt.exe treats ';' as a tab/command delimiter in EVERY argument, regardless of +// quoting, and there is no escape that survives (microsoft/terminal#13264). So the +// free-text prompt must never appear on the wt command line. We hand it to PowerShell +// out-of-band via an environment variable and reference it as $env:VAR — PowerShell +// binds a variable's value as a single argument without re-tokenizing it, so the prompt +// is robust to ';', '&', quotes, and newlines. All other (controlled) tokens are +// single-quoted. No cmd shim: cmd re-parses %VAR% and would re-introduce the problem. + +using System.Diagnostics; +using System.Text; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Planning; + +// Spawns the Claude CLI inside a visible Windows Terminal window. Used for every +// human-driven session: an interactive planning session (start/resume) and the ad-hoc +// "Run interactively" action. Headless task execution does NOT come through here — that +// path is ClaudeProcess (prompt over stdin, no terminal). +public sealed class WindowsTerminalLauncher : ITerminalLauncher +{ + private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill"; + private const string Model = ModelRegistry.PlanningAlias; + + // Carries the free-text initial prompt to PowerShell out-of-band (never on the + // command line) so wt.exe cannot split it on ';'. + private const string PromptEnvVar = "CLAUDEDO_LAUNCH_PROMPT"; + + private readonly string _wtPath; + private readonly string _claudePath; + + public WindowsTerminalLauncher(string wtPath, string claudePath) + { + _wtPath = wtPath; + _claudePath = claudePath; + } + + public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new TerminalLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + if (!File.Exists(ctx.Files.SystemPromptPath)) + throw new TerminalLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}"); + if (!File.Exists(ctx.Files.InitialPromptPath)) + throw new TerminalLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}"); + + var resolvedWt = ResolveWtOrThrow(); + var resolvedClaude = ResolveClaudeOrThrow(); + + // Arg order: --allowedTools is variadic (space-separated). The positional prompt + // must follow a single-value flag, or it will be swallowed — + // --append-system-prompt-file serves as that buffer. + var command = BuildPwshCommand(resolvedClaude, new[] + { + "--model", Model, + "--permission-mode", "plan", + "--allowedTools", AllowedTools, + "--append-system-prompt-file", ctx.Files.SystemPromptPath, + }, appendPrompt: true); + + StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => + { + env["MAX_THINKING_TOKENS"] = "20000"; + env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; + env[PromptEnvVar] = File.ReadAllText(ctx.Files.InitialPromptPath); + }); + + return Task.CompletedTask; + } + + public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new TerminalLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + var resolvedWt = ResolveWtOrThrow(); + var resolvedClaude = ResolveClaudeOrThrow(); + + var command = BuildPwshCommand(resolvedClaude, new[] + { + "--model", Model, + "--permission-mode", "auto", + }, appendPrompt: true); + + StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => + { + env["MAX_THINKING_TOKENS"] = "20000"; + env[PromptEnvVar] = ctx.InitialPrompt; + }); + + return Task.CompletedTask; + } + + public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new TerminalLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + var resolvedWt = ResolveWtOrThrow(); + var resolvedClaude = ResolveClaudeOrThrow(); + + var command = BuildPwshCommand(resolvedClaude, new[] + { + "--permission-mode", "plan", + "--resume", ctx.ClaudeSessionId, + }, appendPrompt: false); + + StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => + { + env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; + }); + + return Task.CompletedTask; + } + + private string ResolveWtOrThrow() => + Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}"); + + private string ResolveClaudeOrThrow() => + Resolve(_claudePath) ?? throw new TerminalLaunchException($"claude executable not found: {_claudePath}"); + + // Builds the PowerShell command that invokes claude with the given (controlled) + // arguments, optionally appending the free-text prompt from $env:CLAUDEDO_LAUNCH_PROMPT. + // The prompt is referenced as a variable so PowerShell binds its value as ONE argument + // (never re-tokenized). The `-replace '"','\"'` works around Windows PowerShell 5.1's + // native-argument quirk, which otherwise strips embedded double-quotes before the child + // sees them; all other characters (';', '&', spaces, backslashes, newlines) pass through. + private static string BuildPwshCommand(string claudePath, IReadOnlyList args, bool appendPrompt) + { + var sb = new StringBuilder(); + sb.Append("& ").Append(PwshQuote(claudePath)); + foreach (var a in args) + sb.Append(' ').Append(PwshQuote(a)); + if (appendPrompt) + sb.Append(" ($env:").Append(PromptEnvVar).Append(" -replace '\"','\\\"')"); + return sb.ToString(); + } + + // Single-quote for PowerShell: only the single quote itself is special, escaped by doubling. + private static string PwshQuote(string value) => "'" + value.Replace("'", "''") + "'"; + + private static void StartInWindowsTerminal( + string resolvedWt, string workingDir, string pwshCommand, Action> configureEnv) + { + var psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = false, + CreateNoWindow = false, + }; + + configureEnv(psi.Environment); + + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(workingDir); + psi.ArgumentList.Add("powershell"); + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-NoLogo"); + psi.ArgumentList.Add("-Command"); + psi.ArgumentList.Add(pwshCommand); + + _ = Process.Start(psi) + ?? throw new TerminalLaunchException("Failed to start Windows Terminal process."); + } + + private static string? Resolve(string pathOrName) + { + if (File.Exists(pathOrName)) + return pathOrName; + + // Try PATH resolution + var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var extensions = new[] { "", ".exe", ".cmd", ".bat" }; + foreach (var dir in envPath.Split(Path.PathSeparator)) + { + foreach (var ext in extensions) + { + var candidate = Path.Combine(dir, pathOrName + ext); + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } +} diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs deleted file mode 100644 index 3769b64..0000000 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs +++ /dev/null @@ -1,168 +0,0 @@ -// Claude CLI flags (verified 2026-04-23 via Context7): -// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists) -// Allowed-tools: --allowedTools (camelCase), comma-separated tokens -// System prompt: --append-system-prompt-file (file form) -// Session ID: no pre-assign flag; resume with --resume -// Launch model: wt.exe directly spawns claude.exe via argv (UseShellExecute=false). -// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise. - -using System.Diagnostics; -using ClaudeDo.Data.Models; - -namespace ClaudeDo.Worker.Planning; - -public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher -{ - private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill"; - private const string Model = ModelRegistry.PlanningAlias; - - private readonly string _wtPath; - private readonly string _claudePath; - - public WindowsTerminalPlanningLauncher(string wtPath, string claudePath) - { - _wtPath = wtPath; - _claudePath = claudePath; - } - - public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) - { - if (!Directory.Exists(ctx.WorkingDir)) - throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); - - if (!File.Exists(ctx.Files.SystemPromptPath)) - throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}"); - if (!File.Exists(ctx.Files.InitialPromptPath)) - throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}"); - - var resolvedWt = Resolve(_wtPath); - if (resolvedWt is null) - throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); - - var resolvedClaude = Resolve(_claudePath); - if (resolvedClaude is null) - throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); - - var psi = new ProcessStartInfo - { - FileName = resolvedWt, - UseShellExecute = false, - CreateNoWindow = false, - }; - - psi.Environment["MAX_THINKING_TOKENS"] = "20000"; - psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; - - // Arg order: --allowedTools is variadic (space-separated). The positional - // prompt must follow a single-value flag, or it will be swallowed. - // --append-system-prompt-file serves as that buffer. - psi.ArgumentList.Add("-d"); - psi.ArgumentList.Add(ctx.WorkingDir); - psi.ArgumentList.Add(resolvedClaude); - psi.ArgumentList.Add("--model"); - psi.ArgumentList.Add(Model); - psi.ArgumentList.Add("--permission-mode"); - psi.ArgumentList.Add("plan"); - psi.ArgumentList.Add("--allowedTools"); - psi.ArgumentList.Add(AllowedTools); - psi.ArgumentList.Add("--append-system-prompt-file"); - psi.ArgumentList.Add(ctx.Files.SystemPromptPath); - psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath)); - - var proc = Process.Start(psi) - ?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); - - return Task.CompletedTask; - } - - public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken) - { - if (!Directory.Exists(ctx.WorkingDir)) - throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); - - var resolvedWt = Resolve(_wtPath) - ?? throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); - var resolvedClaude = Resolve(_claudePath) - ?? throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); - - var psi = new ProcessStartInfo - { - FileName = resolvedWt, - UseShellExecute = false, - CreateNoWindow = false, - }; - - psi.Environment["MAX_THINKING_TOKENS"] = "20000"; - - psi.ArgumentList.Add("-d"); - psi.ArgumentList.Add(ctx.WorkingDir); - psi.ArgumentList.Add(resolvedClaude); - psi.ArgumentList.Add("--model"); - psi.ArgumentList.Add(Model); - psi.ArgumentList.Add("--permission-mode"); - psi.ArgumentList.Add("auto"); - psi.ArgumentList.Add(ctx.InitialPrompt); - - var proc = Process.Start(psi) - ?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); - - return Task.CompletedTask; - } - - public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) - { - if (!Directory.Exists(ctx.WorkingDir)) - throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); - - var resolvedWt = Resolve(_wtPath); - if (resolvedWt is null) - throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); - - var resolvedClaude = Resolve(_claudePath); - if (resolvedClaude is null) - throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); - - var psi = new ProcessStartInfo - { - FileName = resolvedWt, - UseShellExecute = false, - CreateNoWindow = false, - }; - - psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; - - psi.ArgumentList.Add("-d"); - psi.ArgumentList.Add(ctx.WorkingDir); - psi.ArgumentList.Add(resolvedClaude); - psi.ArgumentList.Add("--permission-mode"); - psi.ArgumentList.Add("plan"); - psi.ArgumentList.Add("--resume"); - psi.ArgumentList.Add(ctx.ClaudeSessionId); - - var proc = Process.Start(psi) - ?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); - - return Task.CompletedTask; - } - - private static string? Resolve(string pathOrName) - { - if (File.Exists(pathOrName)) - return pathOrName; - - // Try PATH resolution - var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - var extensions = new[] { "", ".exe", ".cmd", ".bat" }; - foreach (var dir in envPath.Split(Path.PathSeparator)) - { - foreach (var ext in extensions) - { - var candidate = Path.Combine(dir, pathOrName + ext); - if (File.Exists(candidate)) - return candidate; - } - } - - return null; - } -} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index a5daaea..07343d0 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -147,8 +147,8 @@ builder.Services.AddHostedService(sp => new PlanningLineageRecovery( sp.GetRequiredService>(), planningSessionsDir, sp.GetRequiredService>())); -builder.Services.AddSingleton(sp => - new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin)); +builder.Services.AddSingleton(sp => + new WindowsTerminalLauncher("wt.exe", cfg.ClaudeBin)); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index 95167a5..ee97f21 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -24,7 +24,7 @@ public sealed class PlanningHubTests : IDisposable private readonly ListRepository _lists; private readonly string _rootDir; private readonly PlanningSessionManager _planning; - private readonly FakePlanningLauncher _launcher; + private readonly FakeTerminalLauncher _launcher; private readonly RecordingClientProxy _proxy; public PlanningHubTests() @@ -40,7 +40,7 @@ public sealed class PlanningHubTests : IDisposable var built = TaskStateServiceBuilder.Build(_db.CreateFactory()); _planning = new PlanningSessionManager( _tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain); - _launcher = new FakePlanningLauncher(); + _launcher = new FakeTerminalLauncher(); _proxy = new RecordingClientProxy(); } @@ -110,7 +110,7 @@ public sealed class PlanningHubTests : IDisposable _launcher.ShouldThrow = true; var hub = CreateHub(); - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => hub.StartPlanningSessionAsync(taskId)); var loaded = await _tasks.GetByIdAsync(taskId); @@ -173,21 +173,21 @@ public sealed class PlanningHubTests : IDisposable // Fakes // --------------------------------------------------------------------------- -internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher +internal sealed class FakeTerminalLauncher : ITerminalLauncher { public bool ShouldThrow { get; set; } public int LaunchStartCalls { get; private set; } public int LaunchResumeCalls { get; private set; } public int LaunchInteractiveCalls { get; private set; } - public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) + public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) { - if (ShouldThrow) throw new PlanningLaunchException("fake launch failure"); + if (ShouldThrow) throw new TerminalLaunchException("fake launch failure"); LaunchStartCalls++; return Task.CompletedTask; } - public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) + public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) { LaunchResumeCalls++; return Task.CompletedTask; @@ -195,7 +195,7 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken) { - if (ShouldThrow) throw new PlanningLaunchException("fake launch failure"); + if (ShouldThrow) throw new TerminalLaunchException("fake launch failure"); LaunchInteractiveCalls++; return Task.CompletedTask; } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs similarity index 69% rename from tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs rename to tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs index e547bb4..f45038c 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs @@ -2,7 +2,7 @@ using ClaudeDo.Worker.Planning; namespace ClaudeDo.Worker.Tests.Planning; -public sealed class WindowsTerminalPlanningLauncherTests +public sealed class WindowsTerminalLauncherTests { private static PlanningSessionStartContext MakeStartCtx(string? wd = null) { @@ -22,27 +22,27 @@ public sealed class WindowsTerminalPlanningLauncherTests } [Fact] - public async Task LaunchStartAsync_WorkingDirMissing_Throws() + public async Task LaunchPlanningStartAsync_WorkingDirMissing_Throws() { var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid())); - var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude"); - var ex = await Assert.ThrowsAsync(() => - sut.LaunchStartAsync(ctx, CancellationToken.None)); + var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchPlanningStartAsync(ctx, CancellationToken.None)); Assert.Contains("Working directory", ex.Message); } [Fact] - public async Task LaunchStartAsync_WtMissing_Throws() + public async Task LaunchPlanningStartAsync_WtMissing_Throws() { var ctx = MakeStartCtx(); File.WriteAllText(ctx.Files.SystemPromptPath, "sp"); File.WriteAllText(ctx.Files.InitialPromptPath, "ip"); - var sut = new WindowsTerminalPlanningLauncher( + var sut = new WindowsTerminalLauncher( wtPath: "C:/no/such/wt.exe", claudePath: "claude"); - var ex = await Assert.ThrowsAsync(() => - sut.LaunchStartAsync(ctx, CancellationToken.None)); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchPlanningStartAsync(ctx, CancellationToken.None)); Assert.Contains("Windows Terminal", ex.Message); } }