StreamingClaudeSession.RemoveQueuedAsync drops the first occurrence of a queued message from _pending and re-broadcasts the updated queue. Wired through InteractiveSessionService + WorkerHub.RemoveQueuedInteractiveMessage + IWorkerClient.RemoveQueuedInteractiveMessageAsync. Removal by text (first match) is robust to a turn flushing mid-click. Fakes + ILiveSession impls updated.
304 lines
9.5 KiB
C#
304 lines
9.5 KiB
C#
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<string, IReadOnlyList<string>, Func<string, Task>, (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<InvalidOperationException>(
|
|
() => 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<InvalidOperationException>(
|
|
() => svc.StartAsync(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_RegistersSessionAndBroadcastsStarted()
|
|
{
|
|
var (_, taskId, _) = await SeedAsync();
|
|
var fakeSession = new FakeLiveSession();
|
|
var exitTcs = new TaskCompletionSource<bool>();
|
|
|
|
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<bool>();
|
|
|
|
var svc = CreateService((_, __, ___) => (fakeSession, exitTcs.Task));
|
|
await svc.StartAsync(taskId, CancellationToken.None);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => 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<bool>();
|
|
|
|
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<bool>();
|
|
|
|
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<InvalidOperationException>(
|
|
() => 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<bool>();
|
|
|
|
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<string, Task>? capturedOnLine = null;
|
|
var fakeSession = new FakeLiveSession();
|
|
var exitTcs = new TaskCompletionSource<bool>();
|
|
|
|
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;
|
|
}
|