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.
This commit is contained in:
mika kuns
2026-07-23 16:47:16 +02:00
parent d8194ad57e
commit c412a84fdf
40 changed files with 23 additions and 2338 deletions
@@ -25,10 +25,6 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string>? InteractiveSessionStartedEvent;
public event Action<string>? InteractiveSessionEndedEvent;
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
public event Action<string, string>? InteractiveMessageSentEvent;
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
@@ -59,11 +55,6 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
public void RaisePrepFinished(bool ok) => PrepFinishedEvent?.Invoke(ok);
public void RaiseInteractiveStarted(string taskId) => InteractiveSessionStartedEvent?.Invoke(taskId);
public void RaiseInteractiveEnded(string taskId) => InteractiveSessionEndedEvent?.Invoke(taskId);
public void RaiseInteractiveQueueChanged(string taskId, IReadOnlyList<string> pending) => InteractiveQueueChangedEvent?.Invoke(taskId, pending);
public void RaiseInteractiveMessageSent(string taskId, string text) => InteractiveMessageSentEvent?.Invoke(taskId, text);
public virtual bool IsConnected => false;
public virtual bool IsReconnecting => false;
public virtual string? LastApproveTarget => null;
@@ -103,7 +94,6 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
@@ -151,30 +141,6 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
public List<(string TaskId, string Text)> SentInteractive { get; } = new();
public virtual Task SendInteractiveMessageAsync(string taskId, string text)
{
SentInteractive.Add((taskId, text));
return Task.CompletedTask;
}
public List<(string TaskId, string Text)> RemovedQueued { get; } = new();
public virtual Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
{
RemovedQueued.Add((taskId, text));
return Task.CompletedTask;
}
public List<string> StoppedInteractive { get; } = new();
public virtual Task StopInteractiveSessionAsync(string taskId)
{
StoppedInteractive.Add(taskId);
return Task.CompletedTask;
}
public List<string> InterruptedInteractive { get; } = new();
public virtual Task InterruptInteractiveSessionAsync(string taskId)
{
InterruptedInteractive.Add(taskId);
return Task.CompletedTask;
}
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
@@ -189,224 +189,4 @@ public class TaskMonitorViewModelTests : IDisposable
Assert.False(vm.HasPendingQuestion);
}
// ── Interactive composer ──────────────────────────────────────────────────
[Fact]
public void InteractiveStarted_ForSubscribedTask_SetsIsInteractiveLive()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
Assert.True(vm.IsInteractiveLive);
Assert.Equal("running", vm.AgentState);
}
[Fact]
public void InteractiveStarted_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("other");
Assert.False(vm.IsInteractiveLive);
}
[Fact]
public void InteractiveEnded_ForSubscribedTask_ClearsIsInteractiveLive()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
worker.RaiseInteractiveEnded("t1");
Assert.False(vm.IsInteractiveLive);
Assert.Equal("done", vm.AgentState);
}
[Fact]
public void InteractiveEnded_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
worker.RaiseInteractiveEnded("other");
Assert.True(vm.IsInteractiveLive); // unchanged
}
[Fact]
public void SubmitComposerCommand_CanExecute_FalseWhenNotLive()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
vm.ComposerDraft = "hello";
Assert.False(vm.SubmitComposerCommand.CanExecute(null));
}
[Fact]
public void SubmitComposerCommand_CanExecute_FalseWhenLiveButDraftWhitespace()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
vm.ComposerDraft = " ";
Assert.False(vm.SubmitComposerCommand.CanExecute(null));
}
[Fact]
public void SubmitComposerCommand_CanExecute_TrueWhenLiveAndDraftSet()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
vm.ComposerDraft = "hello";
Assert.True(vm.SubmitComposerCommand.CanExecute(null));
}
[Fact]
public async Task SubmitComposer_CallsClient_ClearsDraft_DoesNotAddLogLine()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
vm.ComposerDraft = "do the thing";
await vm.SubmitComposerCommand.ExecuteAsync(null);
Assert.Single(worker.SentInteractive);
Assert.Equal(("t1", "do the thing"), worker.SentInteractive[0]);
Assert.Equal(string.Empty, vm.ComposerDraft);
// Log must NOT be updated by submit itself; it updates on InteractiveMessageSent
Assert.Empty(vm.Log);
}
[Fact]
public void InteractiveMessageSent_ForSubscribedTask_AddsUserLogLine()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveMessageSent("t1", "hello from event");
Assert.Single(vm.Log);
Assert.Equal(LogKind.User, vm.Log[0].Kind);
Assert.Equal("hello from event", vm.Log[0].Text);
}
[Fact]
public void InteractiveMessageSent_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveMessageSent("other", "not mine");
Assert.Empty(vm.Log);
}
[Fact]
public void InteractiveQueueChanged_ForSubscribedTask_PopulatesQueue()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1", "msg2" });
Assert.Equal(2, vm.QueuedMessages.Count);
Assert.Equal("msg1", vm.QueuedMessages[0].Text);
Assert.Equal("msg2", vm.QueuedMessages[1].Text);
Assert.True(vm.HasQueuedMessages);
}
[Fact]
public void InteractiveQueueChanged_EmptyList_ClearsQueue()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1" });
worker.RaiseInteractiveQueueChanged("t1", Array.Empty<string>());
Assert.Empty(vm.QueuedMessages);
Assert.False(vm.HasQueuedMessages);
}
[Fact]
public void InteractiveQueueChanged_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("other", new[] { "msg1" });
Assert.Empty(vm.QueuedMessages);
Assert.False(vm.HasQueuedMessages);
}
[Fact]
public void InteractiveEnded_ClearsQueuedMessages()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "pending msg" });
worker.RaiseInteractiveEnded("t1");
Assert.Empty(vm.QueuedMessages);
Assert.False(vm.HasQueuedMessages);
}
[Fact]
public async Task QueuedMessageViewModel_RemoveCommand_RecordsRemoveCall()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "a", "b" });
vm.QueuedMessages[0].RemoveCommand.Execute(null);
// RemoveQueuedAsync is fire-and-forget; yield to let the async continuation run
await System.Threading.Tasks.Task.Yield();
Assert.Single(worker.RemovedQueued);
Assert.Equal(("t1", "a"), worker.RemovedQueued[0]);
}
[Fact]
public async Task InterruptInteractiveCommand_WhenLive_RecordsOneCall()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
await vm.InterruptInteractiveCommand.ExecuteAsync(null);
Assert.Single(worker.InterruptedInteractive);
Assert.Equal("t1", worker.InterruptedInteractive[0]);
}
}
@@ -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(), null!, null!);
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
@@ -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(), null!, null!);
cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return (hub, inboxCfg, store);
@@ -57,7 +57,7 @@ public sealed class PlanningHubTests : IDisposable
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!, null!);
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;
@@ -52,7 +52,7 @@ public sealed class SessionSkillsHubTests : 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(), null!, registry);
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), registry);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return (hub, registry);
@@ -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(), null!, null!);
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
@@ -1,47 +0,0 @@
using ClaudeDo.Worker.Runner.Interfaces;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public sealed class FakeClaudeStreamTransport : IClaudeStreamTransport
{
public List<string> Written { get; } = [];
public bool Killed { get; private set; }
public bool Started { get; private set; }
public event Func<string, Task>? LineReceived;
public event Func<string, Task>? StderrReceived;
public Task StartAsync(IReadOnlyList<string> args, string workingDirectory, CancellationToken ct)
{
Started = true;
return Task.CompletedTask;
}
public Task WriteLineAsync(string jsonLine, CancellationToken ct)
{
Written.Add(jsonLine);
return Task.CompletedTask;
}
public void Kill() => Killed = true;
public Task WaitForExitAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// Test helper: push a simulated stdout line to all LineReceived subscribers.
public async Task PushLineAsync(string line)
{
var handler = LineReceived;
if (handler is not null)
await handler(line);
}
// Test helper: push a simulated stderr line.
public async Task PushStderrAsync(string line)
{
var handler = StderrReceived;
if (handler is not null)
await handler(line);
}
}
@@ -1,303 +0,0 @@
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;
}
@@ -1,145 +0,0 @@
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 { get; set; }
public Task SendUserMessageAsync(string text, CancellationToken ct) => Task.CompletedTask;
public Task RemoveQueuedAsync(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
}
[Fact]
public async Task ReapIdleAsync_StopsAndRemovesIdleSession()
{
var registry = new LiveSessionRegistry();
var session = new FakeLiveSession();
registry.Register("task-1", session);
// Sweep "now" is an hour past registration, well beyond the 30-min idle window.
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow.AddMinutes(60), TimeSpan.FromMinutes(30));
Assert.Contains("task-1", reaped);
Assert.True(session.StopCalled);
Assert.False(registry.TryGet("task-1", out _));
}
[Fact]
public async Task ReapIdleAsync_KeepsRecentlyActiveSession()
{
var registry = new LiveSessionRegistry();
var session = new FakeLiveSession();
registry.Register("task-1", session);
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow, TimeSpan.FromMinutes(30));
Assert.Empty(reaped);
Assert.False(session.StopCalled);
Assert.True(registry.TryGet("task-1", out _));
}
[Fact]
public async Task ReapIdleAsync_SkipsSessionWithTurnInFlight()
{
var registry = new LiveSessionRegistry();
var session = new FakeLiveSession { IsTurnInFlight = true };
registry.Register("task-1", session);
// Idle long enough to reap, but a turn is in flight → must be left alone.
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow.AddMinutes(60), TimeSpan.FromMinutes(30));
Assert.Empty(reaped);
Assert.False(session.StopCalled);
Assert.True(registry.TryGet("task-1", out _));
}
[Fact]
public async Task ReapIdleAsync_NoSessions_ReturnsEmpty()
{
var registry = new LiveSessionRegistry();
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow, TimeSpan.FromMinutes(30));
Assert.Empty(reaped);
}
}
@@ -1,469 +0,0 @@
using System.Text.Json;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class StreamingClaudeSessionTests
{
private static StreamingClaudeSession Build(
FakeClaudeStreamTransport transport,
List<string> received)
{
return new StreamingClaudeSession(
transport,
line => { received.Add(line); return Task.CompletedTask; },
NullLogger<StreamingClaudeSession>.Instance);
}
private static string ResultLine(bool isError = false, string subtype = "success") =>
JsonSerializer.Serialize(new { type = "result", is_error = isError, subtype });
private static string UserMessageLine(string text) =>
JsonSerializer.Serialize(new
{
type = "user",
message = new { role = "user", content = new[] { new { type = "text", text } } },
parent_tool_use_id = (string?)null
});
// ---- Start sends first prompt as user-message, IsTurnInFlight = true ----
[Fact]
public async Task Start_SendsFirstPromptAsUserMessage_AndTurnIsInFlight()
{
var transport = new FakeClaudeStreamTransport();
var received = new List<string>();
var session = Build(transport, received);
await session.StartAsync([], "/tmp", "hello world", CancellationToken.None);
Assert.True(session.IsTurnInFlight);
Assert.Single(transport.Written);
using var doc = JsonDocument.Parse(transport.Written[0]);
var root = doc.RootElement;
Assert.Equal("user", root.GetProperty("type").GetString());
var text = root.GetProperty("message").GetProperty("content")[0].GetProperty("text").GetString();
Assert.Equal("hello world", text);
await session.DisposeAsync();
}
// ---- Pushing a result line flips IsTurnInFlight to false ----
[Fact]
public async Task PushingResultLine_FlipsIsTurnInFlightToFalse()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "prompt", CancellationToken.None);
Assert.True(session.IsTurnInFlight);
await transport.PushLineAsync(ResultLine());
Assert.False(session.IsTurnInFlight);
await session.DisposeAsync();
}
// ---- Sending while in-flight queues the message; no interrupt written ----
[Fact]
public async Task SendWhileInFlight_QueuesMessage_NoInterrupt()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
// Written[0] = first user message. Turn is in flight.
Assert.True(session.IsTurnInFlight);
var countBefore = transport.Written.Count;
await session.SendUserMessageAsync("second", CancellationToken.None);
// Nothing extra written yet — message is queued, no interrupt issued.
Assert.Equal(countBefore, transport.Written.Count);
Assert.True(session.IsTurnInFlight);
await session.DisposeAsync();
}
// ---- Queued message flushes automatically when result arrives ----
[Fact]
public async Task QueuedMessage_FlushesOnResult()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await session.SendUserMessageAsync("second", CancellationToken.None);
// Push result — should dequeue "second" and send it.
await transport.PushLineAsync(ResultLine());
// After flush: IsTurnInFlight is true again for the second turn.
Assert.True(session.IsTurnInFlight);
// Written[0] = "first", Written[1] = "second" user message.
Assert.Equal(2, transport.Written.Count);
using var doc = JsonDocument.Parse(transport.Written[1]);
Assert.Equal("user", doc.RootElement.GetProperty("type").GetString());
var text = doc.RootElement.GetProperty("message").GetProperty("content")[0].GetProperty("text").GetString();
Assert.Equal("second", text);
await session.DisposeAsync();
}
// ---- Interrupt writes control_request when in-flight ----
[Fact]
public async Task Interrupt_WritesControlRequest_WhenInFlight()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await session.SendUserMessageAsync("second", CancellationToken.None); // queued
await session.InterruptAsync(CancellationToken.None);
// Written[0] = first user message, Written[1] = interrupt control_request.
Assert.True(transport.Written.Count >= 2);
using var interruptDoc = JsonDocument.Parse(transport.Written[1]);
Assert.Equal("control_request", interruptDoc.RootElement.GetProperty("type").GetString());
Assert.Equal("interrupt", interruptDoc.RootElement.GetProperty("request").GetProperty("subtype").GetString());
// Now push result — queued "second" must flush.
await transport.PushLineAsync(ResultLine());
Assert.True(session.IsTurnInFlight);
using var userDoc = JsonDocument.Parse(transport.Written[^1]);
Assert.Equal("user", userDoc.RootElement.GetProperty("type").GetString());
var text = userDoc.RootElement.GetProperty("message").GetProperty("content")[0].GetProperty("text").GetString();
Assert.Equal("second", text);
await session.DisposeAsync();
}
// ---- Interrupt is a no-op when idle ----
[Fact]
public async Task Interrupt_NoOp_WhenIdle()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await transport.PushLineAsync(ResultLine()); // idle now
Assert.False(session.IsTurnInFlight);
var countBefore = transport.Written.Count;
await session.InterruptAsync(CancellationToken.None);
Assert.Equal(countBefore, transport.Written.Count);
await session.DisposeAsync();
}
// ---- Sending while idle writes user message with no interrupt ----
[Fact]
public async Task SendWhileIdle_WritesUserMessageWithNoInterrupt()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await transport.PushLineAsync(ResultLine()); // end the turn → idle
Assert.False(session.IsTurnInFlight);
var countBefore = transport.Written.Count;
await session.SendUserMessageAsync("second", CancellationToken.None);
// Exactly one new write, no interrupt.
Assert.Equal(countBefore + 1, transport.Written.Count);
using var doc = JsonDocument.Parse(transport.Written[^1]);
Assert.Equal("user", doc.RootElement.GetProperty("type").GetString());
await session.DisposeAsync();
}
// ---- Result with is_error / error_during_execution still ends the turn ----
[Fact]
public async Task ResultWithIsError_StillEndsTurn_NoThrow()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "prompt", CancellationToken.None);
Assert.True(session.IsTurnInFlight);
await transport.PushLineAsync(ResultLine(isError: true, subtype: "error_during_execution"));
Assert.False(session.IsTurnInFlight);
await session.DisposeAsync();
}
// ---- onLine receives every pushed stdout line ----
[Fact]
public async Task OnLine_ReceivesEveryPushedLine()
{
var transport = new FakeClaudeStreamTransport();
var received = new List<string>();
var session = Build(transport, received);
await session.StartAsync([], "/tmp", "prompt", CancellationToken.None);
var lines = new[] { "{\"type\":\"assistant\"}", "{\"type\":\"stream_event\"}", ResultLine() };
foreach (var l in lines)
await transport.PushLineAsync(l);
Assert.Equal(lines, received);
await session.DisposeAsync();
}
// ---- Multiple queued messages flush one-per-result in FIFO order ----
[Fact]
public async Task MultipleQueued_FlushInFifoOrder()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await session.SendUserMessageAsync("second", CancellationToken.None);
await session.SendUserMessageAsync("third", CancellationToken.None);
// Both "second" and "third" are queued; nothing extra written yet.
Assert.Single(transport.Written);
// Result 1 → flushes "second".
await transport.PushLineAsync(ResultLine());
Assert.Equal(2, transport.Written.Count);
using var doc2 = JsonDocument.Parse(transport.Written[1]);
Assert.Equal("second", doc2.RootElement.GetProperty("message").GetProperty("content")[0].GetProperty("text").GetString());
Assert.True(session.IsTurnInFlight);
// Result 2 → flushes "third".
await transport.PushLineAsync(ResultLine());
Assert.Equal(3, transport.Written.Count);
using var doc3 = JsonDocument.Parse(transport.Written[2]);
Assert.Equal("third", doc3.RootElement.GetProperty("message").GetProperty("content")[0].GetProperty("text").GetString());
Assert.True(session.IsTurnInFlight);
// Result 3 → queue empty, idle.
await transport.PushLineAsync(ResultLine());
Assert.False(session.IsTurnInFlight);
await session.DisposeAsync();
}
// ──────────────────────────────────────────────────────────────────────────
// Callback tests (onQueueChanged / onUserMessageSent)
// ──────────────────────────────────────────────────────────────────────────
private static StreamingClaudeSession BuildWithCallbacks(
FakeClaudeStreamTransport transport,
List<IReadOnlyList<string>> queueChanges,
List<string> sent)
{
return new StreamingClaudeSession(
transport,
line => Task.CompletedTask,
NullLogger<StreamingClaudeSession>.Instance,
onQueueChanged: snapshot => queueChanges.Add(snapshot),
onUserMessageSent: text => sent.Add(text));
}
[Fact]
public async Task Start_InvokesOnUserMessageSent_WithFirstPrompt()
{
var transport = new FakeClaudeStreamTransport();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, [], sent);
await session.StartAsync([], "/tmp", "hello", CancellationToken.None);
Assert.Single(sent);
Assert.Equal("hello", sent[0]);
await session.DisposeAsync();
}
[Fact]
public async Task SendWhileInFlight_InvokesOnQueueChanged_NotOnUserMessageSent()
{
var transport = new FakeClaudeStreamTransport();
var queueChanges = new List<IReadOnlyList<string>>();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, queueChanges, sent);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
sent.Clear(); // ignore the initial prompt notification
await session.SendUserMessageAsync("queued-msg", CancellationToken.None);
Assert.Single(queueChanges);
Assert.Contains("queued-msg", queueChanges[0]);
Assert.DoesNotContain("queued-msg", sent);
await session.DisposeAsync();
}
[Fact]
public async Task PushResult_FlushesPending_InvokesQueueClearThenUserMessageSent()
{
var transport = new FakeClaudeStreamTransport();
var queueChanges = new List<IReadOnlyList<string>>();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, queueChanges, sent);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
sent.Clear();
await session.SendUserMessageAsync("queued-msg", CancellationToken.None);
queueChanges.Clear(); // ignore the enqueue snapshot
await transport.PushLineAsync(ResultLine());
// After flush: one queueChanged with empty list, then sent contains flushed text.
Assert.Single(queueChanges);
Assert.Empty(queueChanges[0]);
Assert.Single(sent);
Assert.Equal("queued-msg", sent[0]);
await session.DisposeAsync();
}
[Fact]
public async Task SendWhileIdle_InvokesOnUserMessageSent_NoQueueChanged()
{
var transport = new FakeClaudeStreamTransport();
var queueChanges = new List<IReadOnlyList<string>>();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, queueChanges, sent);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await transport.PushLineAsync(ResultLine()); // go idle
sent.Clear();
queueChanges.Clear();
await session.SendUserMessageAsync("idle-msg", CancellationToken.None);
Assert.Empty(queueChanges);
Assert.Single(sent);
Assert.Equal("idle-msg", sent[0]);
await session.DisposeAsync();
}
[Fact]
public async Task TwoMessagesQueued_FlushFifo_QueueSnapshotsShrink()
{
var transport = new FakeClaudeStreamTransport();
var queueChanges = new List<IReadOnlyList<string>>();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, queueChanges, sent);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
sent.Clear();
await session.SendUserMessageAsync("second", CancellationToken.None);
await session.SendUserMessageAsync("third", CancellationToken.None);
// queueChanges[0] = ["second"], queueChanges[1] = ["second","third"]
Assert.Equal(2, queueChanges.Count);
Assert.Equal(new[] { "second" }, queueChanges[0]);
Assert.Equal(new[] { "second", "third" }, queueChanges[1]);
queueChanges.Clear();
// Result 1 → flushes "second"; remaining queue = ["third"]
await transport.PushLineAsync(ResultLine());
Assert.Single(queueChanges);
Assert.Equal(new[] { "third" }, queueChanges[0]);
Assert.Single(sent);
Assert.Equal("second", sent[0]);
sent.Clear();
queueChanges.Clear();
// Result 2 → flushes "third"; remaining queue = []
await transport.PushLineAsync(ResultLine());
Assert.Single(queueChanges);
Assert.Empty(queueChanges[0]);
Assert.Single(sent);
Assert.Equal("third", sent[0]);
await session.DisposeAsync();
}
// ──────────────────────────────────────────────────────────────────────────
// RemoveQueuedAsync tests
// ──────────────────────────────────────────────────────────────────────────
[Fact]
public async Task RemoveQueued_RemovesFirstOccurrence_SnapshotContainsOnlySecond_AndSecondDeliveredOnResult()
{
var transport = new FakeClaudeStreamTransport();
var queueChanges = new List<IReadOnlyList<string>>();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, queueChanges, sent);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
sent.Clear();
// Enqueue two messages while turn is in flight.
await session.SendUserMessageAsync("alpha", CancellationToken.None);
await session.SendUserMessageAsync("beta", CancellationToken.None);
queueChanges.Clear();
// Remove "alpha" from the queue.
await session.RemoveQueuedAsync("alpha", CancellationToken.None);
// Snapshot emitted and contains only "beta".
Assert.Single(queueChanges);
Assert.Equal(new[] { "beta" }, queueChanges[0]);
// Push result → only "beta" is flushed, not "alpha".
sent.Clear();
queueChanges.Clear();
await transport.PushLineAsync(ResultLine());
Assert.Single(sent);
Assert.Equal("beta", sent[0]);
// Queue now empty; next result leaves us idle.
await transport.PushLineAsync(ResultLine());
Assert.False(session.IsTurnInFlight);
await session.DisposeAsync();
}
[Fact]
public async Task RemoveQueued_NotFound_NoQueueChangedCallback()
{
var transport = new FakeClaudeStreamTransport();
var queueChanges = new List<IReadOnlyList<string>>();
var sent = new List<string>();
var session = BuildWithCallbacks(transport, queueChanges, sent);
await session.StartAsync([], "/tmp", "first", CancellationToken.None);
await session.SendUserMessageAsync("alpha", CancellationToken.None);
queueChanges.Clear();
// Try to remove a message that is not in the queue.
await session.RemoveQueuedAsync("nope", CancellationToken.None);
// No new snapshot emitted.
Assert.Empty(queueChanges);
await session.DisposeAsync();
}
}
@@ -36,10 +36,6 @@ sealed class FakeWorkerClient : IWorkerClient
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string>? InteractiveSessionStartedEvent;
public event Action<string>? InteractiveSessionEndedEvent;
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
public event Action<string, string>? InteractiveMessageSentEvent;
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
@@ -73,7 +69,6 @@ sealed class FakeWorkerClient : IWorkerClient
public Task CancelReviewAsync(string taskId) => Task.CompletedTask;
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public int PickUpInTerminalCalls { get; private set; }
public string? LastPickUpTaskId { get; private set; }
public Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) { PickUpInTerminalCalls++; LastPickUpTaskId = taskId; return Task.CompletedTask; }
@@ -137,10 +132,6 @@ sealed class FakeWorkerClient : IWorkerClient
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
public Task SendInteractiveMessageAsync(string taskId, string text) => Task.CompletedTask;
public Task RemoveQueuedInteractiveMessageAsync(string taskId, string text) => Task.CompletedTask;
public Task StopInteractiveSessionAsync(string taskId) => Task.CompletedTask;
public Task InterruptInteractiveSessionAsync(string taskId) => Task.CompletedTask;
public IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>();
}