feat(worker): in-app interactive session service, replacing the wt terminal launch

InteractiveSessionService resolves a task's list working dir + seeded prompt,
spawns a StreamingClaudeSession (claude stream-json in the list dir, model+auto
as before), registers it in LiveSessionRegistry, streams output over TaskMessage,
and broadcasts InteractiveSessionStarted/Ended (an exit watcher fires Ended). The
hub's OpenInteractiveTerminalAsync now starts this in-app session; SendInteractiveMessage
and StopInteractiveSession route to it. The external Windows-Terminal interactive
launch (LaunchInteractiveAsync / InteractiveLaunchContext / OpenInteractiveAsync) is
removed; planning sessions keep their terminal launch.
This commit is contained in:
Mika Kuns
2026-06-26 09:17:11 +02:00
parent d8a043fae7
commit 30e87e698e
13 changed files with 475 additions and 84 deletions

View File

@@ -21,7 +21,7 @@ public sealed class ClearMyDayHubTests : IDisposable
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -31,7 +31,7 @@ public sealed class OnlineInboxHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return (hub, inboxCfg, store);

View File

@@ -57,7 +57,7 @@ public sealed class PlanningHubTests : IDisposable
null!, null!, null!, null!, null!, null!, 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());
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;
@@ -179,7 +179,6 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher
public bool ShouldThrow { get; set; }
public int LaunchStartCalls { get; private set; }
public int LaunchResumeCalls { get; private set; }
public int LaunchInteractiveCalls { get; private set; }
public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
@@ -193,13 +192,6 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher
LaunchResumeCalls++;
return Task.CompletedTask;
}
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchInteractiveCalls++;
return Task.CompletedTask;
}
}
internal sealed class RecordingClientProxy : IClientProxy

View File

@@ -21,7 +21,7 @@ public sealed class WorktreeStateHubTests : IDisposable
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -0,0 +1,301 @@
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 InterruptAsync(CancellationToken ct) => Task.CompletedTask;
public Task StopAsync()
{
Stopped = true;
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}