Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
T
mika kuns c412a84fdf refactor(interactive): remove streaming interactive stack (superseded by ConPTY)
The embedded ConPTY terminal replaced the in-app streaming interactive session, so
delete the dead stack: StreamingClaudeSession, InteractiveSessionService,
ProcessClaudeStreamTransport, IClaudeStreamTransport, ILiveSession, LiveSessionRegistry,
IdleSessionReaper (+ WorkerConfig.InteractiveIdleTimeoutMinutes), the WorkerHub
interactive methods + HubBroadcaster events, IWorkerClient interactive members, the
TaskMonitorViewModel composer + SessionTerminalView composer markup, and the old
'Run interactively' entry. AskUser/PendingQuestionRegistry, the autonomous path,
planning, ResumeTaskInTerminal, and all ConPTY code are kept. Localization pruned.
2026-07-23 16:47:16 +02:00

333 lines
12 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Worktrees;
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 FakeTerminalLauncher _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}");
var git = new GitService();
var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
var settingsRepo = new AppSettingsRepository(_ctx);
settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
var built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_planning = new PlanningSessionManager(
_tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain);
_launcher = new FakeTerminalLauncher();
_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!, _db.CreateFactory(), null!, null!, null!,
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
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}");
GitRepoFixture.InitRepoWithInitialCommit(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.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "feat",
};
await _tasks.AddAsync(task);
return (listId, task.Id);
}
private async Task SeedRunAsync(string taskId, string? sessionId)
{
await new TaskRunRepository(_ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
RunNumber = 1,
IsRetry = false,
Prompt = "p",
SessionId = sessionId,
});
}
private async Task<string> SeedWorktreeAsync(string taskId, WorktreeState state)
{
var path = Path.Combine(_rootDir, $"wt_{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
await new WorktreeRepository(_ctx).AddAsync(new WorktreeEntity
{
TaskId = taskId,
Path = path,
BranchName = "claudedo/x",
BaseCommit = "base",
State = state,
CreatedAt = DateTime.UtcNow,
});
return path;
}
[Fact]
public async Task ResumeTaskInTerminal_LaunchesWithWorktreePathAndSessionId()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-99");
var wtPath = await SeedWorktreeAsync(taskId, WorktreeState.Active);
var hub = CreateHub();
await hub.ResumeTaskInTerminal(taskId);
Assert.Equal(1, _launcher.LaunchTerminalResumeCalls);
Assert.Equal(wtPath, _launcher.LastResumeWorkingDir);
Assert.Equal("sess-99", _launcher.LastResumeSessionId);
}
[Fact]
public async Task ResumeTaskInTerminal_NoSession_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, sessionId: null);
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task ResumeTaskInTerminal_RunningTask_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-1");
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var task = await _tasks.GetByIdAsync(taskId);
task!.Status = TaskStatus.Running;
await _tasks.UpdateAsync(task);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task ResumeTaskInTerminal_NoWorktree_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-1");
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[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.Idle, loaded!.Status);
Assert.Equal(PlanningPhase.Active, loaded.PlanningPhase);
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<TerminalLaunchException>(() =>
hub.StartPlanningSessionAsync(taskId));
var loaded = await _tasks.GetByIdAsync(taskId);
Assert.Equal(TaskStatus.Idle, loaded!.Status);
Assert.Equal(PlanningPhase.None, loaded.PlanningPhase);
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.Idle, loaded!.Status);
Assert.Equal(PlanningPhase.None, loaded.PlanningPhase);
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);
await _tasks.CreateChildAsync(taskId, "child 2", 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);
await _tasks.CreateChildAsync(taskId, "c2", null, null);
var hub = CreateHub();
var count = await hub.GetPendingDraftCountAsync(taskId);
Assert.Equal(2, count);
}
}
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
internal sealed class FakeTerminalLauncher : ITerminalLauncher
{
public bool ShouldThrow { get; set; }
public int LaunchStartCalls { get; private set; }
public int LaunchResumeCalls { get; private set; }
public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchStartCalls++;
return Task.CompletedTask;
}
public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
LaunchResumeCalls++;
return Task.CompletedTask;
}
public int LaunchTerminalResumeCalls { get; private set; }
public string? LastResumeWorkingDir { get; private set; }
public string? LastResumeSessionId { get; private set; }
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchTerminalResumeCalls++;
LastResumeWorkingDir = workingDir;
LastResumeSessionId = claudeSessionId;
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() { }
}