Compare commits
2 Commits
77f7cf1423
...
feat/plann
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b52140ce | ||
|
|
e7d595244e |
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
@@ -10,8 +8,6 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
namespace ClaudeDo.Data.Migrations
|
namespace ClaudeDo.Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[DbContext(typeof(ClaudeDoDbContext))]
|
|
||||||
[Migration("20260416064948_InitialCreate")]
|
|
||||||
public partial class InitialCreate : Migration
|
public partial class InitialCreate : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Migrations
|
namespace ClaudeDo.Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[DbContext(typeof(ClaudeDoDbContext))]
|
|
||||||
[Migration("20260420075929_AddTaskFlagsAndNotes")]
|
|
||||||
public partial class AddTaskFlagsAndNotes : Migration
|
public partial class AddTaskFlagsAndNotes : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Migrations
|
namespace ClaudeDo.Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[DbContext(typeof(ClaudeDoDbContext))]
|
|
||||||
[Migration("20260421113614_AddAppSettings")]
|
|
||||||
public partial class AddAppSettings : Migration
|
public partial class AddAppSettings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
@@ -7,8 +5,6 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
namespace ClaudeDo.Data.Migrations
|
namespace ClaudeDo.Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[DbContext(typeof(ClaudeDoDbContext))]
|
|
||||||
[Migration("20260422120000_AddTaskSortOrder")]
|
|
||||||
public partial class AddTaskSortOrder : Migration
|
public partial class AddTaskSortOrder : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
@@ -8,8 +6,6 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
namespace ClaudeDo.Data.Migrations
|
namespace ClaudeDo.Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[DbContext(typeof(ClaudeDoDbContext))]
|
|
||||||
[Migration("20260423154708_AddPlanningSupport")]
|
|
||||||
public partial class AddPlanningSupport : Migration
|
public partial class AddPlanningSupport : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ namespace ClaudeDo.Ui.Converters;
|
|||||||
|
|
||||||
public sealed class BoolToDraftOpacityConverter : IValueConverter
|
public sealed class BoolToDraftOpacityConverter : IValueConverter
|
||||||
{
|
{
|
||||||
public static BoolToDraftOpacityConverter Instance { get; } = new();
|
|
||||||
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
=> value is true ? 0.7 : 1.0;
|
=> value is true ? 0.7 : 1.0;
|
||||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ namespace ClaudeDo.Ui.Converters;
|
|||||||
|
|
||||||
public sealed class BoolToItalicConverter : IValueConverter
|
public sealed class BoolToItalicConverter : IValueConverter
|
||||||
{
|
{
|
||||||
public static BoolToItalicConverter Instance { get; } = new();
|
|
||||||
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
|
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
|
||||||
|
public bool IsPlanning => Status == TaskStatus.Planning;
|
||||||
|
public bool IsPlanned => Status == TaskStatus.Planned;
|
||||||
public bool IsDraft => Status == TaskStatus.Draft;
|
public bool IsDraft => Status == TaskStatus.Draft;
|
||||||
|
|
||||||
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||||||
@@ -77,6 +79,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(HasLiveTail));
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
OnPropertyChanged(nameof(IsPlanningParent));
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
OnPropertyChanged(nameof(IsPlanning));
|
||||||
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
OnPropertyChanged(nameof(PlanningBadge));
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
|
|||||||
@@ -99,8 +99,11 @@
|
|||||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||||
<TextBlock Text="DRAFT"/>
|
<TextBlock Text="DRAFT"/>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
|
<Border Classes="badge planning" IsVisible="{Binding IsPlanning}">
|
||||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
<TextBlock Text="PLANNING"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planned" IsVisible="{Binding IsPlanned}">
|
||||||
|
<TextBlock Text="PLANNED"/>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace ClaudeDo.Worker.Planning;
|
|
||||||
|
|
||||||
public sealed record PlanningSessionStartContext(
|
|
||||||
string ParentTaskId,
|
|
||||||
string WorkingDir,
|
|
||||||
PlanningSessionFiles Files);
|
|
||||||
|
|
||||||
public sealed record PlanningSessionResumeContext(
|
|
||||||
string ParentTaskId,
|
|
||||||
string WorkingDir,
|
|
||||||
string ClaudeSessionId,
|
|
||||||
string McpConfigPath);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ClaudeDo.Worker.Planning;
|
|
||||||
|
|
||||||
public sealed record PlanningSessionFiles(
|
|
||||||
string SessionDirectory,
|
|
||||||
string McpConfigPath,
|
|
||||||
string SystemPromptPath,
|
|
||||||
string InitialPromptPath);
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Planning;
|
|
||||||
|
|
||||||
public sealed class PlanningSessionManager
|
|
||||||
{
|
|
||||||
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
|
|
||||||
|
|
||||||
private readonly TaskRepository _tasks;
|
|
||||||
private readonly ListRepository _lists;
|
|
||||||
private readonly string _rootDirectory;
|
|
||||||
|
|
||||||
public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
|
|
||||||
{
|
|
||||||
_tasks = tasks;
|
|
||||||
_lists = lists;
|
|
||||||
_rootDirectory = rootDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, ct)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.ParentTaskId is not null)
|
|
||||||
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
|
||||||
if (task.Status != TaskStatus.Manual)
|
|
||||||
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
|
||||||
|
|
||||||
var token = GenerateToken();
|
|
||||||
_ = await _tasks.SetPlanningStartedAsync(taskId, token, ct)
|
|
||||||
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
|
||||||
|
|
||||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
||||||
Directory.CreateDirectory(sessionDir);
|
|
||||||
|
|
||||||
var files = new PlanningSessionFiles(
|
|
||||||
sessionDir,
|
|
||||||
Path.Combine(sessionDir, "mcp.json"),
|
|
||||||
Path.Combine(sessionDir, "system-prompt.md"),
|
|
||||||
Path.Combine(sessionDir, "initial-prompt.txt"));
|
|
||||||
|
|
||||||
await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(token), ct);
|
|
||||||
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
|
||||||
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, ct)
|
|
||||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
|
||||||
return new PlanningSessionStartContext(taskId, list.WorkingDir, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
|
||||||
=> _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
|
||||||
|
|
||||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var ok = await _tasks.DiscardPlanningAsync(taskId, ct);
|
|
||||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
||||||
if (Directory.Exists(sessionDir))
|
|
||||||
{
|
|
||||||
try { Directory.Delete(sessionDir, recursive: true); }
|
|
||||||
catch { /* best effort */ }
|
|
||||||
}
|
|
||||||
if (!ok)
|
|
||||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, ct)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Status != TaskStatus.Planning)
|
|
||||||
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
|
||||||
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
|
||||||
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
|
||||||
|
|
||||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
||||||
var mcpConfigPath = Path.Combine(sessionDir, "mcp.json");
|
|
||||||
if (!File.Exists(mcpConfigPath))
|
|
||||||
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, ct)
|
|
||||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
|
||||||
return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateToken()
|
|
||||||
{
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
|
||||||
return Convert.ToBase64String(bytes)
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_')
|
|
||||||
.TrimEnd('=');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildMcpConfigJson(string token)
|
|
||||||
{
|
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
mcpServers = new
|
|
||||||
{
|
|
||||||
claudedo = new
|
|
||||||
{
|
|
||||||
type = "http",
|
|
||||||
url = McpServerUrl,
|
|
||||||
headers = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["Authorization"] = $"Bearer {token}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildSystemPrompt() =>
|
|
||||||
"""
|
|
||||||
You are a planning assistant for ClaudeDo.
|
|
||||||
Your role is to help break down a task into smaller, actionable subtasks.
|
|
||||||
|
|
||||||
Use the available MCP tools (mcp__claudedo__*) to create child tasks.
|
|
||||||
When you are done planning, finalize the session.
|
|
||||||
|
|
||||||
Be concise and focused. Each subtask should be independently executable.
|
|
||||||
""";
|
|
||||||
|
|
||||||
private static string BuildInitialPrompt(TaskEntity task)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine($"# Task: {task.Title}");
|
|
||||||
if (!string.IsNullOrWhiteSpace(task.Description))
|
|
||||||
{
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine(task.Description);
|
|
||||||
}
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("---");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("Please analyze this task and break it down into concrete subtasks.");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable
|
|||||||
public DbFixture()
|
public DbFixture()
|
||||||
{
|
{
|
||||||
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
||||||
// EnsureCreated uses the current model directly — no Designer.cs needed.
|
// Apply migrations so the schema is created.
|
||||||
using var ctx = CreateContext();
|
using var ctx = CreateContext();
|
||||||
ctx.Database.EnsureCreated();
|
ctx.Database.Migrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClaudeDoDbContext CreateContext()
|
public ClaudeDoDbContext CreateContext()
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Worker.Planning;
|
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Planning;
|
|
||||||
|
|
||||||
public sealed class PlanningSessionManagerTests : 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 _sut;
|
|
||||||
|
|
||||||
public PlanningSessionManagerTests()
|
|
||||||
{
|
|
||||||
_ctx = _db.CreateContext();
|
|
||||||
_tasks = new TaskRepository(_ctx);
|
|
||||||
_lists = new ListRepository(_ctx);
|
|
||||||
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}");
|
|
||||||
_sut = new PlanningSessionManager(_tasks, _lists, _rootDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_ctx.Dispose();
|
|
||||||
_db.Dispose();
|
|
||||||
try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string listId, string workingDir)> SeedListAsync()
|
|
||||||
{
|
|
||||||
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 = "Test",
|
|
||||||
WorkingDir = wd,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
return (listId, wd);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TaskEntity> SeedManualTaskAsync(string listId)
|
|
||||||
{
|
|
||||||
var t = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "Brainstorm auth",
|
|
||||||
Description = "- review tokens\n- plan rollout",
|
|
||||||
Status = TaskStatus.Manual,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "feat",
|
|
||||||
};
|
|
||||||
await _tasks.AddAsync(t);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning()
|
|
||||||
{
|
|
||||||
var (listId, wd) = await SeedListAsync();
|
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
|
||||||
|
|
||||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(parent.Id, ctx.ParentTaskId);
|
|
||||||
Assert.Equal(wd, ctx.WorkingDir);
|
|
||||||
Assert.True(File.Exists(ctx.Files.McpConfigPath));
|
|
||||||
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
|
|
||||||
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
|
|
||||||
|
|
||||||
var mcp = await File.ReadAllTextAsync(ctx.Files.McpConfigPath);
|
|
||||||
Assert.Contains("\"type\": \"http\"", mcp);
|
|
||||||
Assert.Contains("Bearer ", mcp);
|
|
||||||
|
|
||||||
var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath);
|
|
||||||
Assert.Contains("Brainstorm auth", initial);
|
|
||||||
Assert.Contains("review tokens", initial);
|
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
|
||||||
Assert.NotNull(loaded.PlanningSessionToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_TaskNotManual_Throws()
|
|
||||||
{
|
|
||||||
var (listId, _) = await SeedListAsync();
|
|
||||||
var queuedTask = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "x",
|
|
||||||
Status = TaskStatus.Queued,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "feat",
|
|
||||||
};
|
|
||||||
await _tasks.AddAsync(queuedTask);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
_sut.StartAsync(queuedTask.Id, CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StartAsync_ChildTask_Throws()
|
|
||||||
{
|
|
||||||
var (listId, _) = await SeedListAsync();
|
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
|
||||||
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
|
||||||
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
_sut.StartAsync(child.Id, CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ResumeAsync_ReturnsExistingSessionDetails()
|
|
||||||
{
|
|
||||||
var (listId, wd) = await SeedListAsync();
|
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
|
||||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
||||||
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42");
|
|
||||||
|
|
||||||
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(parent.Id, resumeCtx.ParentTaskId);
|
|
||||||
Assert.Equal(wd, resumeCtx.WorkingDir);
|
|
||||||
Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId);
|
|
||||||
Assert.Equal(startCtx.Files.McpConfigPath, resumeCtx.McpConfigPath);
|
|
||||||
Assert.True(File.Exists(resumeCtx.McpConfigPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ResumeAsync_NotPlanning_Throws()
|
|
||||||
{
|
|
||||||
var (listId, _) = await SeedListAsync();
|
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
|
||||||
// did not start
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ResumeAsync_NoClaudeSessionId_Throws()
|
|
||||||
{
|
|
||||||
var (listId, _) = await SeedListAsync();
|
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
|
||||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
||||||
// UpdatePlanningSessionIdAsync not called
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned()
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(2, count);
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
|
|
||||||
{
|
|
||||||
var (listId, _) = await SeedListAsync();
|
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
|
||||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
||||||
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
|
||||||
|
|
||||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
|
||||||
Assert.Null(loaded.PlanningSessionToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user