feat(worker): queue interactive messages by default, interrupt opt-in

StreamingClaudeSession now buffers a mid-turn user message in a FIFO queue and
flushes one when the turn's result arrives (no implicit interrupt). InterruptAsync
only writes the control_request (no-op when idle); the resulting turn-end then
flushes any queued message. New InteractiveSessionService.InterruptAsync +
WorkerHub.InterruptInteractiveSession + IWorkerClient.InterruptInteractiveSessionAsync.
This commit is contained in:
Mika Kuns
2026-06-26 10:35:13 +02:00
parent 9c292e5080
commit bdda98eccd
8 changed files with 175 additions and 45 deletions

View File

@@ -150,6 +150,12 @@ public abstract class StubWorkerClient : IWorkerClient
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));
}

View File

@@ -69,10 +69,10 @@ public sealed class StreamingClaudeSessionTests
await session.DisposeAsync();
}
// ---- Sending while in-flight: interrupt first, then user message after result ----
// ---- Sending while in-flight queues the message; no interrupt written ----
[Fact]
public async Task SendWhileInFlight_WritesInterruptFirst_ThenUserMessage()
public async Task SendWhileInFlight_QueuesMessage_NoInterrupt()
{
var transport = new FakeClaudeStreamTransport();
var session = Build(transport, []);
@@ -80,27 +80,67 @@ public sealed class StreamingClaudeSessionTests
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;
// Fire the second send on a background task (it will block waiting for the turn to end).
var sendTask = Task.Run(() => session.SendUserMessageAsync("second", CancellationToken.None));
await session.SendUserMessageAsync("second", CancellationToken.None);
// Give the background task time to reach the await-turn-ended point.
await Task.Delay(50);
// Nothing extra written yet — message is queued, no interrupt issued.
Assert.Equal(countBefore, transport.Written.Count);
Assert.True(session.IsTurnInFlight);
// Push a result line to unblock it.
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());
await sendTask;
// After flush: IsTurnInFlight is true again for the second turn.
Assert.True(session.IsTurnInFlight);
// Written[0] = first prompt, Written[1] = interrupt, Written[2] = second user message.
Assert.True(transport.Written.Count >= 3, $"Expected ≥3 writes, got {transport.Written.Count}");
// 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);
// Written[1] must be an interrupt control_request.
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());
// Last write must be the user message with "second".
// 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();
@@ -109,6 +149,26 @@ public sealed class StreamingClaudeSessionTests
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]
@@ -170,4 +230,40 @@ public sealed class StreamingClaudeSessionTests
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();
}
}

View File

@@ -126,6 +126,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
public Task SendInteractiveMessageAsync(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>();
}