Compare commits
10 Commits
77f7cf1423
...
0782ba574b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0782ba574b | ||
|
|
7b67e35720 | ||
|
|
c048264b95 | ||
|
|
6cb20a9213 | ||
|
|
99c6a71e4c | ||
|
|
0088d6e0e0 | ||
|
|
b115a4c512 | ||
|
|
9e09ae6b4e | ||
|
|
43a3740980 | ||
|
|
d28164caf4 |
@@ -267,6 +267,23 @@ public sealed class TaskRepository
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePlanningTaskAsync(
|
||||||
|
string taskId,
|
||||||
|
string? title,
|
||||||
|
string? description,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||||
|
?? throw new InvalidOperationException("Planning task not found.");
|
||||||
|
if (title is not null) entity.Title = title;
|
||||||
|
if (description is not null) entity.Description = description;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Title, entity.Title)
|
||||||
|
.SetProperty(t => t.Description, entity.Description), ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Services;
|
using ClaudeDo.Worker.Services;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -43,6 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
private readonly TaskResetService _resetService;
|
private readonly TaskResetService _resetService;
|
||||||
private readonly TaskMergeService _mergeService;
|
private readonly TaskMergeService _mergeService;
|
||||||
|
private readonly PlanningSessionManager _planning;
|
||||||
|
private readonly IPlanningTerminalLauncher _launcher;
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -52,7 +55,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
WorktreeMaintenanceService wtMaintenance,
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
TaskResetService resetService,
|
TaskResetService resetService,
|
||||||
TaskMergeService mergeService)
|
TaskMergeService mergeService,
|
||||||
|
PlanningSessionManager planning,
|
||||||
|
IPlanningTerminalLauncher launcher)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_agentService = agentService;
|
_agentService = agentService;
|
||||||
@@ -62,6 +67,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_wtMaintenance = wtMaintenance;
|
_wtMaintenance = wtMaintenance;
|
||||||
_resetService = resetService;
|
_resetService = resetService;
|
||||||
_mergeService = mergeService;
|
_mergeService = mergeService;
|
||||||
|
_planning = planning;
|
||||||
|
_launcher = launcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Ping() => $"pong v{Version}";
|
public string Ping() => $"pong v{Version}";
|
||||||
@@ -284,5 +291,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
await _broadcaster.TaskUpdated(dto.TaskId);
|
await _broadcaster.TaskUpdated(dto.TaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
|
||||||
|
{
|
||||||
|
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
catch (PlanningLaunchException)
|
||||||
|
{
|
||||||
|
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
|
||||||
|
{
|
||||||
|
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
||||||
|
await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DiscardPlanningSessionAsync(string taskId)
|
||||||
|
{
|
||||||
|
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
||||||
|
{
|
||||||
|
var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId)
|
||||||
|
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
|
||||||
|
|
||||||
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
12
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public interface IPlanningTerminalLauncher
|
||||||
|
{
|
||||||
|
Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||||
|
Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningLaunchException : Exception
|
||||||
|
{
|
||||||
|
public PlanningLaunchException(string message) : base(message) { }
|
||||||
|
}
|
||||||
6
src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
Normal file
6
src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningMcpContext
|
||||||
|
{
|
||||||
|
public required string ParentTaskId { get; init; }
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
Normal file
14
src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningMcpContextAccessor
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
|
||||||
|
public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http;
|
||||||
|
|
||||||
|
public PlanningMcpContext Current =>
|
||||||
|
(_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext)
|
||||||
|
?? throw new InvalidOperationException("No planning context on request.");
|
||||||
|
}
|
||||||
128
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
Normal file
128
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags);
|
||||||
|
public sealed record CreatedChildDto(string TaskId, string Status);
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class PlanningMcpService
|
||||||
|
{
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly PlanningMcpContextAccessor _contextAccessor;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
public PlanningMcpService(
|
||||||
|
TaskRepository tasks,
|
||||||
|
PlanningMcpContextAccessor contextAccessor,
|
||||||
|
HubBroadcaster broadcaster)
|
||||||
|
{
|
||||||
|
_tasks = tasks;
|
||||||
|
_contextAccessor = contextAccessor;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
||||||
|
=> _broadcaster.TaskUpdated(taskId);
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")]
|
||||||
|
public async Task<CreatedChildDto> CreateChildTask(
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tags,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
return new CreatedChildDto(child.Id, "Draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
|
||||||
|
public async Task<IReadOnlyList<ChildTaskDto>> ListChildTasks(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
var list = new List<ChildTaskDto>(children.Count);
|
||||||
|
foreach (var c in children)
|
||||||
|
{
|
||||||
|
var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
|
||||||
|
list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")]
|
||||||
|
public async Task<ChildTaskDto> UpdateChildTask(
|
||||||
|
string taskId,
|
||||||
|
string? title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tags,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||||
|
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||||
|
if (child.Status != TaskStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Cannot modify a finalized task.");
|
||||||
|
|
||||||
|
if (title is not null) child.Title = title;
|
||||||
|
if (description is not null) child.Description = description;
|
||||||
|
if (commitType is not null) child.CommitType = commitType;
|
||||||
|
await _tasks.UpdateAsync(child, cancellationToken);
|
||||||
|
|
||||||
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
|
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")]
|
||||||
|
public async Task DeleteChildTask(
|
||||||
|
string taskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||||
|
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||||
|
if (child.Status != TaskStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Cannot delete a finalized task.");
|
||||||
|
|
||||||
|
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Update the title and/or description of the parent planning task itself.")]
|
||||||
|
public async Task UpdatePlanningTask(
|
||||||
|
string? title,
|
||||||
|
string? description,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
|
||||||
|
public async Task<int> Finalize(
|
||||||
|
bool queueAgentTasks,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Planning;
|
namespace ClaudeDo.Worker.Planning;
|
||||||
@@ -11,20 +13,40 @@ public sealed class PlanningSessionManager
|
|||||||
{
|
{
|
||||||
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
|
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
|
||||||
|
|
||||||
private readonly TaskRepository _tasks;
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
|
||||||
private readonly ListRepository _lists;
|
private readonly TaskRepository? _tasksOverride;
|
||||||
|
private readonly ListRepository? _listsOverride;
|
||||||
private readonly string _rootDirectory;
|
private readonly string _rootDirectory;
|
||||||
|
|
||||||
|
// DI constructor — uses factory so this singleton can create scoped repos per call.
|
||||||
|
public PlanningSessionManager(IDbContextFactory<ClaudeDoDbContext> factory, string rootDirectory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_rootDirectory = rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test constructor — accepts repos directly (single shared context, test-scoped).
|
||||||
public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
|
public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
|
||||||
{
|
{
|
||||||
_tasks = tasks;
|
_tasksOverride = tasks;
|
||||||
_lists = lists;
|
_listsOverride = lists;
|
||||||
_rootDirectory = rootDirectory;
|
_rootDirectory = rootDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private (TaskRepository tasks, ListRepository lists, ClaudeDoDbContext? ctx) CreateRepos()
|
||||||
|
{
|
||||||
|
if (_tasksOverride is not null)
|
||||||
|
return (_tasksOverride, _listsOverride!, null);
|
||||||
|
var ctx = _factory!.CreateDbContext();
|
||||||
|
return (new TaskRepository(ctx), new ListRepository(ctx), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, ct)
|
var (tasks, lists, ctx) = CreateRepos();
|
||||||
|
await using var _ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
if (task.ParentTaskId is not null)
|
if (task.ParentTaskId is not null)
|
||||||
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||||
@@ -32,7 +54,7 @@ public sealed class PlanningSessionManager
|
|||||||
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||||
|
|
||||||
var token = GenerateToken();
|
var token = GenerateToken();
|
||||||
_ = await _tasks.SetPlanningStartedAsync(taskId, token, ct)
|
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
||||||
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||||
|
|
||||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
@@ -48,17 +70,32 @@ public sealed class PlanningSessionManager
|
|||||||
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
||||||
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, ct)
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
return new PlanningSessionStartContext(taskId, list.WorkingDir, files);
|
return new PlanningSessionStartContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), files);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
=> _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
{
|
||||||
|
var (tasks, _, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
return await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, _, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||||
|
return children.Count(c => c.Status == TaskStatus.Draft);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var ok = await _tasks.DiscardPlanningAsync(taskId, ct);
|
var (tasks, _, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
|
||||||
|
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
||||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
if (Directory.Exists(sessionDir))
|
if (Directory.Exists(sessionDir))
|
||||||
{
|
{
|
||||||
@@ -71,7 +108,10 @@ public sealed class PlanningSessionManager
|
|||||||
|
|
||||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, ct)
|
var (tasks, lists, ctx) = CreateRepos();
|
||||||
|
await using var _ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
if (task.Status != TaskStatus.Planning)
|
if (task.Status != TaskStatus.Planning)
|
||||||
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||||
@@ -83,9 +123,9 @@ public sealed class PlanningSessionManager
|
|||||||
if (!File.Exists(mcpConfigPath))
|
if (!File.Exists(mcpConfigPath))
|
||||||
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, ct)
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath);
|
return new PlanningSessionResumeContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), task.PlanningSessionId, mcpConfigPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateToken()
|
private static string GenerateToken()
|
||||||
|
|||||||
40
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
Normal file
40
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningTokenAuthMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks)
|
||||||
|
{
|
||||||
|
if (!ctx.Request.Path.StartsWithSegments("/mcp"))
|
||||||
|
{
|
||||||
|
await _next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var auth = ctx.Request.Headers["Authorization"].ToString();
|
||||||
|
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync("Missing bearer token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = auth.Substring("Bearer ".Length).Trim();
|
||||||
|
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
||||||
|
if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync("Invalid or expired planning token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
|
await _next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
125
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// 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 <path> (file form)
|
||||||
|
// Session ID: no pre-assign flag; resume with --resume <id>
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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 = "claude-sonnet-4-6";
|
||||||
|
|
||||||
|
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.McpConfigPath))
|
||||||
|
throw new PlanningLaunchException($"MCP config file not found: {ctx.Files.McpConfigPath}");
|
||||||
|
|
||||||
|
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.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--model");
|
||||||
|
psi.ArgumentList.Add(Model);
|
||||||
|
psi.ArgumentList.Add("--mcp-config");
|
||||||
|
psi.ArgumentList.Add(ctx.Files.McpConfigPath);
|
||||||
|
psi.ArgumentList.Add("--append-system-prompt-file");
|
||||||
|
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
|
||||||
|
psi.ArgumentList.Add("--allowedTools");
|
||||||
|
psi.ArgumentList.Add(AllowedTools);
|
||||||
|
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 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.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--resume");
|
||||||
|
psi.ArgumentList.Add(ctx.ClaudeSessionId);
|
||||||
|
psi.ArgumentList.Add("--mcp-config");
|
||||||
|
psi.ArgumentList.Add(ctx.McpConfigPath);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
|
|||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.Services;
|
using ClaudeDo.Worker.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -51,6 +52,25 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
|||||||
builder.Services.AddSingleton<QueueService>();
|
builder.Services.AddSingleton<QueueService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||||
|
|
||||||
|
// Planning session services.
|
||||||
|
var planningSessionsDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
".todo-app", "planning-sessions");
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
new PlanningSessionManager(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
planningSessionsDir));
|
||||||
|
builder.Services.AddSingleton<IPlanningTerminalLauncher, WindowsTerminalPlanningLauncher>();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
||||||
|
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
|
builder.Services.AddScoped<TaskRepository>();
|
||||||
|
builder.Services.AddScoped<PlanningMcpService>();
|
||||||
|
builder.Services.AddMcpServer()
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithTools<PlanningMcpService>();
|
||||||
|
|
||||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||||
|
|
||||||
@@ -75,7 +95,9 @@ catch (Exception ex)
|
|||||||
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseMiddleware<PlanningTokenAuthMiddleware>();
|
||||||
app.MapHub<WorkerHub>("/hub");
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
app.MapMcp("/mcp");
|
||||||
|
|
||||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||||
cfg.SignalRPort, cfg.DbPath);
|
cfg.SignalRPort, cfg.DbPath);
|
||||||
|
|||||||
221
tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
Normal file
221
tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Hub;
|
||||||
|
|
||||||
|
public sealed class PlanningHubTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly string _rootDir;
|
||||||
|
private readonly PlanningSessionManager _planning;
|
||||||
|
private readonly FakePlanningLauncher _launcher;
|
||||||
|
private readonly RecordingClientProxy _proxy;
|
||||||
|
|
||||||
|
public PlanningHubTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
|
||||||
|
_planning = new PlanningSessionManager(_tasks, _lists, _rootDir);
|
||||||
|
_launcher = new FakePlanningLauncher();
|
||||||
|
_proxy = new RecordingClientProxy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
try { Directory.Delete(_rootDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerHub CreateHub()
|
||||||
|
{
|
||||||
|
var hub = new WorkerHub(
|
||||||
|
null!, null!, null!, null!, null!, null!, null!, null!,
|
||||||
|
_planning, _launcher);
|
||||||
|
hub.Clients = new FakeHubCallerClients(_proxy);
|
||||||
|
hub.Context = new FakeHubCallerContext();
|
||||||
|
return hub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string listId, string taskId)> SeedAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(wd);
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Do something",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "feat",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
return (listId, task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
var hub = CreateHub();
|
||||||
|
|
||||||
|
var ctx = await hub.StartPlanningSessionAsync(taskId);
|
||||||
|
|
||||||
|
Assert.Equal(taskId, ctx.ParentTaskId);
|
||||||
|
Assert.Equal(1, _launcher.LaunchStartCalls);
|
||||||
|
Assert.Equal(0, _launcher.LaunchResumeCalls);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
|
||||||
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartPlanningSessionAsync_LauncherFails_Discards()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
_launcher.ShouldThrow = true;
|
||||||
|
var hub = CreateHub();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||||
|
hub.StartPlanningSessionAsync(taskId));
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDir, taskId);
|
||||||
|
Assert.False(Directory.Exists(sessionDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
// Put task into Planning state first
|
||||||
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
|
_proxy.Sent.Clear();
|
||||||
|
|
||||||
|
var hub = CreateHub();
|
||||||
|
await hub.DiscardPlanningSessionAsync(taskId);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||||
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
||||||
|
_proxy.Sent.Clear();
|
||||||
|
|
||||||
|
var hub = CreateHub();
|
||||||
|
var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPendingDraftCountAsync_ReturnsCount()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
||||||
|
|
||||||
|
var hub = CreateHub();
|
||||||
|
var count = await hub.GetPendingDraftCountAsync(taskId);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fakes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
||||||
|
{
|
||||||
|
public bool ShouldThrow { get; set; }
|
||||||
|
public int LaunchStartCalls { get; private set; }
|
||||||
|
public int LaunchResumeCalls { get; private set; }
|
||||||
|
|
||||||
|
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
||||||
|
LaunchStartCalls++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LaunchResumeCalls++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RecordingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public List<(string method, object?[] args)> Sent { get; } = new();
|
||||||
|
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Sent.Add((method, args));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeHubCallerClients : IHubCallerClients
|
||||||
|
{
|
||||||
|
private readonly IClientProxy _all;
|
||||||
|
public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
|
||||||
|
|
||||||
|
public IClientProxy All => _all;
|
||||||
|
public IClientProxy Caller => _all;
|
||||||
|
public IClientProxy Others => _all;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => _all;
|
||||||
|
public IClientProxy Client(string connectionId) => _all;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
|
||||||
|
public IClientProxy Group(string groupName) => _all;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
|
||||||
|
public IClientProxy OthersInGroup(string groupName) => _all;
|
||||||
|
public IClientProxy User(string userId) => _all;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => _all;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeHubCallerContext : HubCallerContext
|
||||||
|
{
|
||||||
|
public override string ConnectionId => "test-conn";
|
||||||
|
public override string? UserIdentifier => null;
|
||||||
|
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
||||||
|
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||||
|
public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } =
|
||||||
|
new Microsoft.AspNetCore.Http.Features.FeatureCollection();
|
||||||
|
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||||
|
public override void Abort() { }
|
||||||
|
}
|
||||||
109
tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
Normal file
109
tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
// Inline fakes — test isolation beats DRY; mirrors PlanningMcpServiceTests pattern.
|
||||||
|
file sealed class E2EFakeHttpContextAccessor : IHttpContextAccessor
|
||||||
|
{
|
||||||
|
public HttpContext? HttpContext { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class E2ENullHubClients : IHubClients
|
||||||
|
{
|
||||||
|
public IClientProxy All => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy User(string userId) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => E2ENullClientProxy.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class E2ENullClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public static readonly E2ENullClientProxy Instance = new();
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class E2EFakeHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
public IHubClients Clients { get; } = new E2ENullHubClients();
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningEndToEndTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly PlanningSessionManager _manager;
|
||||||
|
private readonly DefaultHttpContext _httpContext;
|
||||||
|
private readonly PlanningMcpContextAccessor _accessor;
|
||||||
|
private readonly PlanningMcpService _svc;
|
||||||
|
|
||||||
|
public PlanningEndToEndTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
|
||||||
|
_manager = new PlanningSessionManager(_tasks, _lists, root);
|
||||||
|
|
||||||
|
_httpContext = new DefaultHttpContext();
|
||||||
|
_accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext });
|
||||||
|
var broadcaster = new HubBroadcaster(new E2EFakeHubContext());
|
||||||
|
_svc = new PlanningMcpService(_tasks, _accessor, broadcaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartThenCreateThenFinalize_FullFlow()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.GetTempPath();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
||||||
|
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Big Task",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(File.Exists(startCtx.Files.McpConfigPath));
|
||||||
|
|
||||||
|
// Wire the ambient context so _svc reads the correct parent
|
||||||
|
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
|
|
||||||
|
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None);
|
||||||
|
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var count = await _svc.Finalize(true, CancellationToken.None);
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
|
||||||
|
var reload = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, reload!.Status);
|
||||||
|
|
||||||
|
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
|
Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status));
|
||||||
|
}
|
||||||
|
}
|
||||||
181
tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Normal file
181
tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
// Minimal fakes — avoids Moq dependency.
|
||||||
|
file sealed class FakeHttpContextAccessor : IHttpContextAccessor
|
||||||
|
{
|
||||||
|
public HttpContext? HttpContext { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class NullHubClients : IHubClients
|
||||||
|
{
|
||||||
|
public IClientProxy All => NullClientProxy.Instance;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy User(string userId) => NullClientProxy.Instance;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => NullClientProxy.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class NullClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public static readonly NullClientProxy Instance = new();
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class FakeHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
public IHubClients Clients { get; } = new NullHubClients();
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningMcpServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public PlanningMcpServiceTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
private PlanningMcpService BuildSut(string parentTaskId)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
|
||||||
|
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
|
||||||
|
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||||
|
return new PlanningMcpService(_tasks, accessor, broadcaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedPlanningParentAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "p",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
return (await _tasks.GetByIdAsync(parent.Id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildTask_CreatesDraft()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
|
||||||
|
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("Draft", result.Status);
|
||||||
|
var child = await _tasks.GetByIdAsync(result.TaskId);
|
||||||
|
Assert.Equal("My child", child!.Title);
|
||||||
|
Assert.Equal(TaskStatus.Draft, child.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListChildTasks_ReturnsOnlyThisParentsChildren()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var other = await SeedPlanningParentAsync();
|
||||||
|
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
var list = await sut.ListChildTasks(CancellationToken.None);
|
||||||
|
Assert.Single(list);
|
||||||
|
Assert.Equal("mine", list[0].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateChildTask_NotAChild_Throws()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var other = await SeedPlanningParentAsync();
|
||||||
|
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateChildTask_NotDraft_Throws()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChildTask_RemovesDraft()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(c.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdatePlanningTask_SetsTitleAndDescription()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal("new title", loaded!.Title);
|
||||||
|
Assert.Equal("new desc", loaded.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
var count = await sut.Finalize(true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
Assert.Null(loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,21 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPendingDraftCountAsync_ReturnsDraftCount()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null);
|
||||||
|
|
||||||
|
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(3, n);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
|
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
public sealed class WindowsTerminalPlanningLauncherTests
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
Files: new PlanningSessionFiles(
|
||||||
|
SessionDirectory: dir,
|
||||||
|
McpConfigPath: Path.Combine(dir, "mcp.json"),
|
||||||
|
SystemPromptPath: Path.Combine(dir, "system-prompt.md"),
|
||||||
|
InitialPromptPath: Path.Combine(dir, "initial-prompt.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LaunchStartAsync_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<PlanningLaunchException>(() =>
|
||||||
|
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||||
|
Assert.Contains("Working directory", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LaunchStartAsync_WtMissing_Throws()
|
||||||
|
{
|
||||||
|
var ctx = MakeStartCtx();
|
||||||
|
File.WriteAllText(ctx.Files.McpConfigPath, "{}");
|
||||||
|
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
|
||||||
|
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
|
||||||
|
|
||||||
|
var sut = new WindowsTerminalPlanningLauncher(
|
||||||
|
wtPath: "C:/no/such/wt.exe",
|
||||||
|
claudePath: "claude");
|
||||||
|
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||||
|
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||||
|
Assert.Contains("Windows Terminal", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user