feat(worker): persistent streaming Claude session + live session registry

StreamingClaudeSession drives claude --input-format stream-json over a kept-
open stdin: sends user messages, interrupts the in-flight turn via the verified
control_request protocol, and tracks turn state from result events (treating an
interrupt-aborted error_during_execution result as turn-ended). IClaudeStreamTransport
abstracts the process I/O so it is unit-tested with a fake (no real claude).
LiveSessionRegistry maps taskId -> live session for the hub to route into.

Backs the upcoming in-app interactive sessions; autonomous task execution untouched.
This commit is contained in:
Mika Kuns
2026-06-26 08:56:19 +02:00
parent 10342bc562
commit d8a043fae7
8 changed files with 623 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Runner.Interfaces;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class LiveSessionRegistryTests
{
private sealed class FakeLiveSession : ILiveSession
{
public bool StopCalled { get; private set; }
public bool IsTurnInFlight => false;
public Task SendUserMessageAsync(string text, CancellationToken ct) => Task.CompletedTask;
public Task InterruptAsync(CancellationToken ct) => Task.CompletedTask;
public Task StopAsync()
{
StopCalled = true;
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public void Register_ThenTryGet_ReturnsSession()
{
var registry = new LiveSessionRegistry();
var session = new FakeLiveSession();
registry.Register("task-1", session);
Assert.True(registry.TryGet("task-1", out var retrieved));
Assert.Same(session, retrieved);
}
[Fact]
public void TryGet_Missing_ReturnsFalse()
{
var registry = new LiveSessionRegistry();
Assert.False(registry.TryGet("no-such-task", out _));
}
[Fact]
public void Unregister_RemovesSession()
{
var registry = new LiveSessionRegistry();
registry.Register("task-1", new FakeLiveSession());
registry.Unregister("task-1");
Assert.False(registry.TryGet("task-1", out _));
}
[Fact]
public async Task Register_WhenSessionAlreadyExists_StopsPreviousSession()
{
var registry = new LiveSessionRegistry();
var first = new FakeLiveSession();
var second = new FakeLiveSession();
registry.Register("task-1", first);
registry.Register("task-1", second);
// Give the fire-and-forget stop a tick to run.
await Task.Delay(50);
Assert.True(first.StopCalled);
Assert.True(registry.TryGet("task-1", out var retrieved));
Assert.Same(second, retrieved);
}
[Fact]
public async Task StopAsync_StopsAndRemovesSession()
{
var registry = new LiveSessionRegistry();
var session = new FakeLiveSession();
registry.Register("task-1", session);
await registry.StopAsync("task-1");
Assert.True(session.StopCalled);
Assert.False(registry.TryGet("task-1", out _));
}
[Fact]
public async Task StopAsync_MissingTask_DoesNotThrow()
{
var registry = new LiveSessionRegistry();
await registry.StopAsync("no-such-task"); // should not throw
}
}