feat(worker): broadcast interactive message queue + delivery
StreamingClaudeSession raises onQueueChanged (pending snapshot) and onUserMessageSent (on delivery, incl. the seeded first prompt); InteractiveSessionService forwards these as InteractiveQueueChanged/InteractiveMessageSent broadcasts. Lets the UI show queued messages above the input and move a message into the transcript only when actually delivered to Claude. Client events + fakes updated.
This commit is contained in:
@@ -27,6 +27,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
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,6 +61,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
|
||||
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;
|
||||
|
||||
@@ -266,4 +266,141 @@ public sealed class StreamingClaudeSessionTests
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user