using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner.Interfaces; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Planning; public sealed class InteractiveSessionServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly CapturingHubContext _hubCtx; private readonly HubBroadcaster _broadcaster; private readonly LiveSessionRegistry _registry; private readonly WorkerConfig _cfg; public InteractiveSessionServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _hubCtx = new CapturingHubContext(); _broadcaster = new HubBroadcaster(_hubCtx); _registry = new LiveSessionRegistry(); _cfg = new WorkerConfig(); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private InteractiveSessionService CreateService( Func, Func, (ILiveSession session, Task exitTask)>? factory = null) { return new InteractiveSessionService( _db.CreateFactory(), _cfg, _broadcaster, _registry, NullLoggerFactory.Instance, factory); } private async Task<(string listId, string taskId, string workingDir)> SeedAsync() { var wd = Path.Combine(Path.GetTempPath(), $"iss_wd_{Guid.NewGuid():N}"); Directory.CreateDirectory(wd); var listId = Guid.NewGuid().ToString(); 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 = "My task", Description = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; await _tasks.AddAsync(task); return (listId, task.Id, wd); } [Fact] public async Task StartAsync_MissingWorkingDir_Throws() { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoDir", WorkingDir = "/no/such/dir/ever/exists", CreatedAt = DateTime.UtcNow, }); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; await _tasks.AddAsync(task); var svc = CreateService(); await Assert.ThrowsAsync( () => svc.StartAsync(task.Id, CancellationToken.None)); } [Fact] public async Task StartAsync_NullWorkingDir_Throws() { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "NullDir", WorkingDir = null, CreatedAt = DateTime.UtcNow, }); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; await _tasks.AddAsync(task); var svc = CreateService(); await Assert.ThrowsAsync( () => svc.StartAsync(task.Id, CancellationToken.None)); } [Fact] public async Task StartAsync_RegistersSessionAndBroadcastsStarted() { var (_, taskId, _) = await SeedAsync(); var fakeSession = new FakeLiveSession(); var exitTcs = new TaskCompletionSource(); var svc = CreateService((_, __, ___) => (fakeSession, exitTcs.Task)); await svc.StartAsync(taskId, CancellationToken.None); // Session registered Assert.True(_registry.TryGet(taskId, out var registered)); Assert.Same(fakeSession, registered); // InteractiveSessionStarted broadcast Assert.Contains(_hubCtx.Proxy.Calls, c => c.Method == "InteractiveSessionStarted"); // Cleanup exitTcs.SetResult(true); await Task.Delay(50); // let watcher fire } [Fact] public async Task StartAsync_AlreadyRunning_Throws() { var (_, taskId, _) = await SeedAsync(); var fakeSession = new FakeLiveSession(); var exitTcs = new TaskCompletionSource(); var svc = CreateService((_, __, ___) => (fakeSession, exitTcs.Task)); await svc.StartAsync(taskId, CancellationToken.None); await Assert.ThrowsAsync( () => svc.StartAsync(taskId, CancellationToken.None)); exitTcs.SetResult(true); await Task.Delay(50); } [Fact] public async Task ExitWatcher_UnregistersAndBroadcastsEnded() { var (_, taskId, _) = await SeedAsync(); var fakeSession = new FakeLiveSession(); var exitTcs = new TaskCompletionSource(); var svc = CreateService((_, __, ___) => (fakeSession, exitTcs.Task)); await svc.StartAsync(taskId, CancellationToken.None); // Process exits naturally exitTcs.SetResult(true); // Give the watcher time to run var deadline = DateTime.UtcNow.AddSeconds(2); while (DateTime.UtcNow < deadline) { if (_registry.TryGet(taskId, out _) == false) break; await Task.Delay(10); } Assert.False(_registry.TryGet(taskId, out _)); Assert.Contains(_hubCtx.Proxy.Calls, c => c.Method == "InteractiveSessionEnded"); } [Fact] public async Task SendAsync_RoutesToSession() { var (_, taskId, _) = await SeedAsync(); var fakeSession = new FakeLiveSession(); var exitTcs = new TaskCompletionSource(); var svc = CreateService((_, __, ___) => (fakeSession, exitTcs.Task)); await svc.StartAsync(taskId, CancellationToken.None); await svc.SendAsync(taskId, "hello", CancellationToken.None); Assert.Equal(1, fakeSession.SendCalls); Assert.Equal("hello", fakeSession.LastSentText); exitTcs.SetResult(true); await Task.Delay(50); } [Fact] public async Task SendAsync_NoSession_Throws() { var svc = CreateService(); await Assert.ThrowsAsync( () => svc.SendAsync("nonexistent-task", "text", CancellationToken.None)); } [Fact] public async Task StopAsync_UnregistersSessionAndStopsIt() { var (_, taskId, _) = await SeedAsync(); var fakeSession = new FakeLiveSession(); // Keep the exit task pending so the exit watcher doesn't race with StopAsync. var exitTcs = new TaskCompletionSource(); var svc = CreateService((_, __, ___) => (fakeSession, exitTcs.Task)); await svc.StartAsync(taskId, CancellationToken.None); // Stop before the process exits naturally. await svc.StopAsync(taskId, CancellationToken.None); // Registry is cleared by StopAsync (which calls _registry.StopAsync -> session.StopAsync + TryRemove). Assert.False(_registry.TryGet(taskId, out _)); Assert.True(fakeSession.Stopped); // Let the watcher complete harmlessly. exitTcs.SetResult(true); await Task.Delay(50); } [Fact] public async Task OnLineCallback_BroadcastsTaskMessageWithPrefix() { var (_, taskId, _) = await SeedAsync(); Func? capturedOnLine = null; var fakeSession = new FakeLiveSession(); var exitTcs = new TaskCompletionSource(); var svc = CreateService((_, __, onLine) => { capturedOnLine = onLine; return (fakeSession, exitTcs.Task); }); await svc.StartAsync(taskId, CancellationToken.None); Assert.NotNull(capturedOnLine); await capturedOnLine!("some line"); Assert.Contains(_hubCtx.Proxy.Calls, c => c.Method == "TaskMessage" && c.Args.Length >= 2 && c.Args[1] is string s && s.StartsWith("[stdout] ")); exitTcs.SetResult(true); await Task.Delay(50); } } internal sealed class FakeLiveSession : ILiveSession { public bool IsTurnInFlight => false; public int SendCalls { get; private set; } public string? LastSentText { get; private set; } public bool Stopped { get; private set; } public Task SendUserMessageAsync(string text, CancellationToken ct) { SendCalls++; LastSentText = text; return Task.CompletedTask; } public Task RemoveQueuedAsync(string text, CancellationToken ct) => Task.CompletedTask; public Task InterruptAsync(CancellationToken ct) => Task.CompletedTask; public Task StopAsync() { Stopped = true; return Task.CompletedTask; } public ValueTask DisposeAsync() => ValueTask.CompletedTask; }