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:
mika kuns
2026-07-24 12:12:23 +02:00
parent 2612831a5e
commit ef285b21fd
16 changed files with 249 additions and 24 deletions
@@ -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…",
@@ -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…",
@@ -82,6 +82,11 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
/// <summary>Resumes a planning session and returns its embedded-ConPTY launch spec.</summary>
Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
+6
View File
@@ -519,6 +519,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct);
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
@@ -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<string>? OpenConPtySessionRequested;
// Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY
// pane in the Command Center.
public event Action<string, bool>? 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);
@@ -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 += (_, _) =>
{
@@ -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)
@@ -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)
+32
View File
@@ -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<LaunchSpec> 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<LaunchSpec> 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<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
{
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
@@ -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<string> 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<string> BuildPlanningResumeArgs(string claudeSessionId) =>
new[] { "--permission-mode", "plan", "--resume", claudeSessionId };
private string ResolveWtOrThrow() =>
Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}");
@@ -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<string, string>
{
["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<string, string>
{
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
["MCP_TOOL_TIMEOUT"] = "200000",
};
return new LaunchSpec(
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env);
}
public Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
{
if (!Directory.Exists(directory))
@@ -1,7 +1,18 @@
using ClaudeDo.Worker.Planning;
namespace ClaudeDo.Worker.Runner;
public interface IInteractiveLaunchSpecService
{
/// <summary>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.</summary>
LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx);
/// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
/// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx);
/// <summary>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
@@ -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]