diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 18e5bb40..8753be16 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -266,6 +266,7 @@ "overviewMode": "Übersicht", "closeSession": "Sitzung schließen", "conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}", + "planningTitleSuffix": " (Planung)", "question": { "title": "Claude fragt nach", "placeholder": "Antwort eingeben…", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 0938797f..9a82ee24 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -266,6 +266,7 @@ "overviewMode": "Overview", "closeSession": "Close session", "conptyLaunchFailed": "Couldn't open ConPTY session: {0}", + "planningTitleSuffix": " (Planning)", "question": { "title": "Claude is asking", "placeholder": "Type your answer…", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 64050c2a..b0e32e93 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -82,6 +82,11 @@ public interface IWorkerClient : INotifyPropertyChanged /// Launch spec for an ad-hoc interactive session in an arbitrary directory -- /// no task, no worktree. Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default); + /// Starts a planning session and returns the launch spec for an embedded ConPTY + /// planning terminal (replaces StartPlanningSessionAsync's external wt window). + Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); + /// Resumes a planning session and returns its embedded-ConPTY launch spec. + Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default); Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index acccd4c1..56768cc4 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -519,6 +519,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => await _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct); + public async Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct); + + public async Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("GetPlanningResumeLaunchSpec", taskId, ct); + public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 0d8a6b3a..3c600eed 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -813,19 +813,24 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty); [RelayCommand] - private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) + private void OpenPlanningSession(TaskRowViewModel? row) { if (row is null) return; if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return; - ForegroundHelper.AllowAny(); - try { await _worker!.StartPlanningSessionAsync(row.Id); } - catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); } + // Planning now runs as an embedded ConPTY pane in the Command Center (not an external wt + // window). The shell owns Mission Control, so raise an event; the actual StartAsync happens + // server-side inside GetPlanningStartLaunchSpec when the pane opens. + OpenPlanningConPtyRequested?.Invoke(row.Id, false); } // Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns // the Mission Control view model, so this just raises an event for it to act on. public event Action? OpenConPtySessionRequested; + // Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY + // pane in the Command Center. + public event Action? OpenPlanningConPtyRequested; + [RelayCommand] private void OpenConPtySession(TaskRowViewModel? row) { @@ -865,8 +870,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable switch (choice) { case UnfinishedPlanningModalResult.Resume: - ForegroundHelper.AllowAny(); - await _worker.ResumePlanningSessionAsync(row.Id); + // Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside + // GetPlanningResumeLaunchSpec when the pane opens). + OpenPlanningConPtyRequested?.Invoke(row.Id, true); break; case UnfinishedPlanningModalResult.FinalizeNow: await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 8066f6c4..649f7a06 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -234,6 +234,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable OpenMissionControl(); _ = MissionControl.OpenConPtySessionAsync(taskId); }; + Tasks.OpenPlanningConPtyRequested += (taskId, resume) => + { + OpenMissionControl(); + _ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume); + }; Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Tasks.OpenListSettingsRequested += (_, _) => { diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index c65a8032..b7a3966e 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -256,6 +256,45 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } } + // Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center + // pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId + // like OpenConPtySessionAsync. + public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume) + { + if (string.IsNullOrEmpty(taskId)) return; + if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing) + { + FocusedPane = existing; + return; + } + + var title = taskId; + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); + if (entity?.Title is { Length: > 0 } t) title = t; + } + catch { /* best-effort title lookup */ } + title += Loc.T("missionControl.planningTitleSuffix"); + + try + { + var spec = resume + ? await _worker.GetPlanningResumeLaunchSpecAsync(taskId) + : await _worker.GetPlanningStartLaunchSpecAsync(taskId); + var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env); + var pane = new ConPtyPaneViewModel(taskId, title, descriptor); + pane.ErrorReported += OnConPtyPaneError; + pane.CloseRequested += CloseConPtySession; + ConPtySessions.Add(pane); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message)); + } + } + // Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call // opens a fresh pane, unlike the task-based OpenConPtySessionAsync above. public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory) diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index 316e74e6..cc096214 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -55,10 +55,10 @@ public partial class TaskRowView : UserControl await vm.RemoveFromMyDayCommand.ExecuteAsync(row); } - private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e) + private void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) - await vm.OpenPlanningSessionCommand.ExecuteAsync(row); + vm.OpenPlanningSessionCommand.Execute(row); } private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 3dffca1a..79c97dd8 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -673,6 +673,38 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted); }); + // Starts a planning session (worktree + prompt files + token, task -> Planning) and returns + // the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for + // StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started + // session is discarded (no children exist yet), mirroring StartPlanningSessionAsync. + public Task GetPlanningStartLaunchSpec(string taskId) => HubGuard(async () => + { + if (_interactiveLaunchSpec is null) + throw new InvalidOperationException("Interactive launch spec service is not configured."); + var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted); + try + { + var spec = _interactiveLaunchSpec.BuildPlanningStart(ctx); + await Clients.All.SendAsync("TaskUpdated", taskId); + return spec; + } + catch + { + await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted); + throw; + } + }); + + // Resumes a planning session and returns the ConPTY launch spec (--permission-mode plan + // --resume). ConPTY replacement for ResumePlanningSessionAsync's external wt window. + public Task GetPlanningResumeLaunchSpec(string taskId) => HubGuard(async () => + { + if (_interactiveLaunchSpec is null) + throw new InvalidOperationException("Interactive launch spec service is not configured."); + var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted); + return _interactiveLaunchSpec.BuildPlanningResume(ctx); + }); + public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false) { var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted); diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs index f2c08797..6cad82a2 100644 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs @@ -74,11 +74,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher var resolvedWt = ResolveWtOrThrow(); var resolvedClaude = ResolveClaudeOrThrow(); - var command = BuildPwshCommand(resolvedClaude, new[] - { - "--permission-mode", "plan", - "--resume", ctx.ClaudeSessionId, - }); + var command = BuildPwshCommand(resolvedClaude, BuildPlanningResumeArgs(ctx.ClaudeSessionId)); StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => { @@ -117,13 +113,21 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher // Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the // single-line kickoff prompt is positional, so it must follow a single-value flag // (--append-system-prompt-file) or a variadic flag would swallow it. - internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx) + internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx) => + BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx)); + + // The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY + // planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than + // a pwsh-wrapped command line. Arg order matters: variadic flags (--allowedTools, --add-dir) + // come first; the single-line kickoff prompt is positional, so it must follow a single-value + // flag (--append-system-prompt-file) or a variadic flag would swallow it. + internal static IReadOnlyList BuildPlanningStartArgs(PlanningSessionStartContext ctx) { var kickoff = $"Read the file {ctx.Files.InitialPromptPath} first. It contains the task you must plan. " + "After reading it, begin the planning session as your instructions describe."; - return BuildPwshCommand(claudePath, new[] + return new[] { "--model", Model, "--permission-mode", "plan", @@ -131,9 +135,14 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher "--add-dir", ctx.Files.SessionDirectory, "--append-system-prompt-file", ctx.Files.SystemPromptPath, kickoff, - }); + }; } + // The raw claude CLI args for an interactive planning RESUME, shared with the embedded-ConPTY + // planning path. Pins --permission-mode plan (unlike a plain --resume pickup). + internal static IReadOnlyList BuildPlanningResumeArgs(string claudeSessionId) => + new[] { "--permission-mode", "plan", "--resume", claudeSessionId }; + private string ResolveWtOrThrow() => Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}"); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 65a5ea65..0c5c1651 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -107,6 +107,40 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(worktree.Path, resolvedClaude, args, env); } + public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx) + { + var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) + ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + + // Mirrors the env the wt planning launcher set (MAX_THINKING_TOKENS + the per-session + // planning token); MCP_TOOL_TIMEOUT matches the other embedded-ConPTY specs. Applied to + // the UI process env at spawn time (see PtyTerminalSession) — process-global by design. + var env = new Dictionary + { + ["MAX_THINKING_TOKENS"] = "20000", + ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token, + ["MCP_TOOL_TIMEOUT"] = "200000", + }; + + return new LaunchSpec( + ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env); + } + + public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx) + { + var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) + ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + + var env = new Dictionary + { + ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token, + ["MCP_TOOL_TIMEOUT"] = "200000", + }; + + return new LaunchSpec( + ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env); + } + public Task BuildForDirectoryAsync(string directory, CancellationToken ct) { if (!Directory.Exists(directory)) diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs index 8a3719c9..bfca55e7 100644 --- a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs @@ -1,7 +1,18 @@ +using ClaudeDo.Worker.Planning; + namespace ClaudeDo.Worker.Runner; public interface IInteractiveLaunchSpecService { + /// Maps an already-prepared planning START context (worktree + prompt files + token, + /// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY + /// planning session — same planning CLI args as the wt launcher, planning env carried in Env. + LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx); + + /// Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a + /// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume). + LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx); + /// Builds a LaunchSpec for opening an interactive session in a task's worktree. /// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException /// if it's Running/Queued. If the task has no usable worktree yet, one is created on diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 44c00c05..d0b399a9 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -99,6 +99,10 @@ public abstract class StubWorkerClient : IWorkerClient => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary())); + public virtual Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); + public virtual Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) => Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0)); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs index 2a302aa7..6c0ad077 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs @@ -70,6 +70,31 @@ public sealed class WindowsTerminalLauncherTests 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() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 0d777388..5c65ffc2 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -2,6 +2,7 @@ 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; @@ -342,4 +343,40 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable await Assert.ThrowsAsync( () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); } + + [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); + Assert.Contains("plan", 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); + + Assert.Equal(new[] { "--permission-mode", "plan", "--resume", "sess-42" }, spec.Args); + Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); + Assert.Equal(_worktreeDir, spec.Cwd); + } } diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index d1a31dc6..dd877a7d 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -76,6 +76,12 @@ sealed class FakeWorkerClient : IWorkerClient => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary())); + public int PlanningStartSpecCalls { get; private set; } + public int PlanningResumeSpecCalls { get; private set; } + public Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) + { PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); } + public Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) + { PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); } public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask; public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } public Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) @@ -224,25 +230,29 @@ public class TasksIslandViewModelPlanningTests } [Fact] - public async Task OpenPlanningSession_IgnoresNonIdleRow() + public void OpenPlanningSession_IgnoresNonIdleRow() { var row = MakeRow("t1", TaskStatus.Queued); - var (vm, worker) = VmFactory.Create([row]); + var (vm, _) = VmFactory.Create([row]); + (string TaskId, bool Resume)? raised = null; + vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume); - await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); + vm.OpenPlanningSessionCommand.Execute(row); - Assert.Equal(0, worker.StartPlanningCalls); + Assert.Null(raised); } [Fact] - public async Task OpenPlanningSession_CallsWorkerForIdleRow() + public void OpenPlanningSession_RequestsConPtyPaneForIdleRow() { var row = MakeRow("t1", TaskStatus.Idle); - var (vm, worker) = VmFactory.Create([row]); + var (vm, _) = VmFactory.Create([row]); + (string TaskId, bool Resume)? raised = null; + vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume); - await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); + vm.OpenPlanningSessionCommand.Execute(row); - Assert.Equal(1, worker.StartPlanningCalls); + Assert.Equal(("t1", false), raised); } [Fact]