feat(worker): remove a queued interactive message

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.
This commit is contained in:
Mika Kuns
2026-06-26 11:15:10 +02:00
parent e7fa373a74
commit fd1e38fb7f
11 changed files with 119 additions and 0 deletions

View File

@@ -289,6 +289,8 @@ internal sealed class FakeLiveSession : ILiveSession
return Task.CompletedTask;
}
public Task RemoveQueuedAsync(string text, CancellationToken ct) => Task.CompletedTask;
public Task InterruptAsync(CancellationToken ct) => Task.CompletedTask;
public Task StopAsync()

View File

@@ -11,6 +11,7 @@ public sealed class LiveSessionRegistryTests
public bool IsTurnInFlight => false;
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()

View File

@@ -403,4 +403,67 @@ public sealed class StreamingClaudeSessionTests
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();
}
}

View File

@@ -127,6 +127,7 @@ sealed class FakeWorkerClient : IWorkerClient
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>();