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 received) { return new StreamingClaudeSession( transport, line => { received.Add(line); return Task.CompletedTask; }, NullLogger.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(); 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: interrupt first, then user message after result ---- [Fact] public async Task SendWhileInFlight_WritesInterruptFirst_ThenUserMessage() { 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); // 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)); // Give the background task time to reach the await-turn-ended point. await Task.Delay(50); // Push a result line to unblock it. await transport.PushLineAsync(ResultLine()); await sendTask; // 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[1] must be an interrupt control_request. 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". 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(); } // ---- 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(); 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(); } }