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.
This commit is contained in:
@@ -99,6 +99,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||
=> Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<InvalidOperationException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public int PlanningStartSpecCalls { get; private set; }
|
||||
public int PlanningResumeSpecCalls { get; private set; }
|
||||
public Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
{ PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
|
||||
public Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
{ PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
|
||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
||||
public Task<DiscardPlanningOutcome> 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<TaskRowViewModel?>)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<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
||||
vm.OpenPlanningSessionCommand.Execute(row);
|
||||
|
||||
Assert.Equal(1, worker.StartPlanningCalls);
|
||||
Assert.Equal(("t1", false), raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user