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.
270 lines
9.9 KiB
C#
270 lines
9.9 KiB
C#
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();
|
|
}
|
|
}
|