From 56f7c29e146eddf9ac13fbbf6c668c6c07879f6b Mon Sep 17 00:00:00 2001 From: telli Date: Sat, 2 May 2026 02:30:19 -0700 Subject: [PATCH 1/3] fix provider stream auth failures --- .../Internal/ProviderBackedAgentKernel.cs | 31 +++- .../AnthropicProvider.cs | 25 ++- .../Internal/AnthropicSdkStreamAdapter.cs | 4 +- .../Internal/OpenAiMeaiStreamAdapter.cs | 4 +- .../Models/ProviderStreamFailureClassifier.cs | 146 ++++++++++++++++++ .../OpenAiCompatibleProvider.cs | 1 + .../Runtime/ProviderRuntimeEventFlowTests.cs | 136 ++++++++++++++++ .../Providers/ProviderStreamAdapterTests.cs | 129 ++++++++++++++++ 8 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 src/SharpClaw.Code.Providers/Models/ProviderStreamFailureClassifier.cs diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index 49c9439..f38781b 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using SharpClaw.Code.Agents.Configuration; using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Providers.Abstractions; @@ -23,6 +24,7 @@ public sealed class ProviderBackedAgentKernel( IAuthFlowService authFlowService, ToolCallDispatcher toolCallDispatcher, IOptions loopOptions, + ISystemClock systemClock, ILogger logger) { internal async Task ExecuteAsync( @@ -78,17 +80,21 @@ internal async Task ExecuteAsync( exception); } - if (!authStatus.IsAuthenticated) + var authExpired = ProviderStreamFailureClassifier.IsExpired(authStatus, systemClock.UtcNow); + if (!authStatus.IsAuthenticated || authExpired) { logger.LogWarning( - "Provider {ProviderName} is not authenticated for session {SessionId}.", + "Provider {ProviderName} is not authenticated or its auth status expired for session {SessionId}.", resolvedProviderName, request.Context.SessionId); + var message = authExpired + ? $"Provider '{resolvedProviderName}' authentication expired at {authStatus.ExpiresAtUtc:O}." + : $"Provider '{resolvedProviderName}' is not authenticated."; throw new ProviderExecutionException( resolvedProviderName, requestedModel, ProviderFailureKind.AuthenticationUnavailable, - $"Provider '{resolvedProviderName}' is not authenticated."); + message); } // --- Resolve provider --- @@ -160,6 +166,17 @@ internal async Task ExecuteAsync( { allProviderEvents.Add(providerEvent); + if (providerEvent.IsTerminal + && string.Equals(providerEvent.Kind, "failed", StringComparison.OrdinalIgnoreCase)) + { + var failureKind = ProviderStreamFailureClassifier.ClassifyFailedEvent(providerEvent); + throw new ProviderExecutionException( + resolvedProviderName, + requestedModel, + failureKind, + CreateProviderFailedEventMessage(resolvedProviderName, providerEvent)); + } + if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content)) { iterationTextSegments.Add(providerEvent.Content); @@ -343,4 +360,12 @@ private static ProviderExecutionException CreateMissingProviderException( model, ProviderFailureKind.MissingProvider, $"No provider named '{providerName}' was registered during {stage}."); + + private static string CreateProviderFailedEventMessage(string providerName, ProviderEvent providerEvent) + { + var detail = string.IsNullOrWhiteSpace(providerEvent.Content) + ? "The provider stream ended with a failure event." + : providerEvent.Content; + return $"Provider '{providerName}' stream failed: {detail}"; + } } diff --git a/src/SharpClaw.Code.Providers/AnthropicProvider.cs b/src/SharpClaw.Code.Providers/AnthropicProvider.cs index c370587..1a3bd86 100644 --- a/src/SharpClaw.Code.Providers/AnthropicProvider.cs +++ b/src/SharpClaw.Code.Providers/AnthropicProvider.cs @@ -34,8 +34,9 @@ public Task GetAuthStatusAsync(CancellationToken cancellationToken) hasAuthOptionalRuntime: false)); /// - public async Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var client = CreateClient(); var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault(request.Model, _options.DefaultModel); @@ -86,8 +87,26 @@ public async Task StartStreamAsync(ProviderRequest request logger.LogInformation("Started Anthropic SDK stream for request {RequestId}.", request.Id); - var stream = client.Messages.CreateStreaming(parameters, cancellationToken); - return new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken)); + IAsyncEnumerable stream; + try + { + stream = client.Messages.CreateStreaming(parameters, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (ProviderStreamFailureClassifier.IsAuthenticationFailure(exception)) + { + throw new ProviderExecutionException( + ProviderName, + modelId, + ProviderFailureKind.AuthenticationUnavailable, + $"Provider '{ProviderName}' authentication failed while starting the stream.", + exception); + } + + return Task.FromResult(new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken))); } private AnthropicClient CreateClient() diff --git a/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs b/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs index 49f0c9f..94b6f12 100644 --- a/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs +++ b/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Anthropic.Models.Messages; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Providers.Internal; @@ -42,7 +43,8 @@ public static async IAsyncEnumerable AdaptAsync( } catch (Exception ex) { - streamError = ex.Message; + cancellationToken.ThrowIfCancellationRequested(); + streamError = ProviderStreamFailureClassifier.Describe(ex); moved = false; } diff --git a/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs b/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs index 565acc6..313862b 100644 --- a/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs +++ b/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Providers.Internal; @@ -38,7 +39,8 @@ public static async IAsyncEnumerable AdaptAsync( } catch (Exception ex) { - streamError = ex.Message; + cancellationToken.ThrowIfCancellationRequested(); + streamError = ProviderStreamFailureClassifier.Describe(ex); moved = false; } diff --git a/src/SharpClaw.Code.Providers/Models/ProviderStreamFailureClassifier.cs b/src/SharpClaw.Code.Providers/Models/ProviderStreamFailureClassifier.cs new file mode 100644 index 0000000..6ae7e92 --- /dev/null +++ b/src/SharpClaw.Code.Providers/Models/ProviderStreamFailureClassifier.cs @@ -0,0 +1,146 @@ +using System.ClientModel; +using System.Globalization; +using System.Net; +using System.Reflection; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Providers.Models; + +/// +/// Classifies provider stream failures without changing the provider event contract. +/// +public static class ProviderStreamFailureClassifier +{ + private static readonly string[] StatusPropertyNames = ["StatusCode", "Status"]; + + /// + /// Returns whether an authentication status is already expired. + /// + public static bool IsExpired(AuthStatus authStatus, DateTimeOffset utcNow) + => authStatus.ExpiresAtUtc is { } expiresAt && expiresAt <= utcNow; + + /// + /// Converts an exception into stable failed-event content while preserving HTTP status detail when available. + /// + public static string Describe(Exception exception) + { + var message = string.IsNullOrWhiteSpace(exception.Message) + ? exception.GetType().Name + : exception.Message; + var statusCode = TryGetStatusCode(exception); + return statusCode is null + ? message + : $"HTTP {(int)statusCode.Value} {statusCode.Value}: {message}"; + } + + /// + /// Classifies a terminal provider failed event. + /// + public static ProviderFailureKind ClassifyFailedEvent(ProviderEvent providerEvent) + => LooksLikeAuthenticationFailure(providerEvent.Content) + ? ProviderFailureKind.AuthenticationUnavailable + : ProviderFailureKind.StreamFailed; + + /// + /// Returns whether an exception represents provider authentication or authorization failure. + /// + public static bool IsAuthenticationFailure(Exception exception) + { + var statusCode = TryGetStatusCode(exception); + if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + return true; + } + + var typeName = exception.GetType().Name; + return typeName.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Forbidden", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Authentication", StringComparison.OrdinalIgnoreCase) + || LooksLikeAuthenticationFailure(exception.Message) + || (exception.InnerException is not null && IsAuthenticationFailure(exception.InnerException)); + } + + private static bool LooksLikeAuthenticationFailure(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + var value = message.ToLowerInvariant(); + if (value.Contains("401", StringComparison.Ordinal) + || value.Contains("unauthorized", StringComparison.Ordinal) + || value.Contains("forbidden", StringComparison.Ordinal)) + { + return true; + } + + if ((value.Contains("api key", StringComparison.Ordinal) + || value.Contains("token", StringComparison.Ordinal) + || value.Contains("credential", StringComparison.Ordinal) + || value.Contains("authentication", StringComparison.Ordinal)) + && (value.Contains("expired", StringComparison.Ordinal) + || value.Contains("invalid", StringComparison.Ordinal) + || value.Contains("missing", StringComparison.Ordinal) + || value.Contains("failed", StringComparison.Ordinal))) + { + return true; + } + + return false; + } + + private static HttpStatusCode? TryGetStatusCode(Exception exception) + { + if (exception is HttpRequestException { StatusCode: { } httpStatusCode }) + { + return httpStatusCode; + } + + if (exception is ClientResultException { Status: > 0 } clientResultException) + { + return (HttpStatusCode)clientResultException.Status; + } + + var reflected = TryGetReflectedStatusCode(exception); + if (reflected is not null) + { + return reflected; + } + + return exception.InnerException is null ? null : TryGetStatusCode(exception.InnerException); + } + + private static HttpStatusCode? TryGetReflectedStatusCode(Exception exception) + { + foreach (var propertyName in StatusPropertyNames) + { + var property = exception.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public); + if (property?.GetValue(exception) is not { } value) + { + continue; + } + + if (value is HttpStatusCode httpStatusCode) + { + return httpStatusCode; + } + + if (value is int intStatusCode and > 0) + { + return (HttpStatusCode)intStatusCode; + } + + if (value.GetType().IsEnum) + { + var enumStatusCode = Convert.ToInt32(value, CultureInfo.InvariantCulture); + if (enumStatusCode > 0) + { + return (HttpStatusCode)enumStatusCode; + } + } + } + + return null; + } +} diff --git a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs index a675a16..5c7656b 100644 --- a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs +++ b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs @@ -39,6 +39,7 @@ public Task GetAuthStatusAsync(CancellationToken cancellationToken) /// public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); logger.LogInformation("Starting OpenAI-compatible MEAI stream for request {RequestId}.", request.Id); return Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request, cancellationToken))); } diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs index 9f565a3..44b708c 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs @@ -129,6 +129,84 @@ public async Task RunPrompt_should_fail_when_provider_stream_fails() latestSession!.State.Should().Be(SessionLifecycleState.Failed); } + /// + /// Ensures already-expired provider auth fails before any stream is started. + /// + [Fact] + public async Task RunPrompt_should_fail_when_provider_auth_expired_before_stream() + { + var workspacePath = CreateTemporaryWorkspace(); + using var serviceProvider = CreateRuntimeServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + var runtime = serviceProvider.GetRequiredService(); + var sessionStore = serviceProvider.GetRequiredService(); + + var act = async () => await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "provider auth expired", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "stub-provider", + ["model"] = "stub-model" + }), + CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Kind.Should().Be(ProviderFailureKind.AuthenticationUnavailable); + exception.Which.Message.Should().Contain("expired"); + + var latestSession = await sessionStore.GetLatestAsync(workspacePath, CancellationToken.None); + latestSession.Should().NotBeNull(); + latestSession!.State.Should().Be(SessionLifecycleState.Failed); + } + + /// + /// Ensures auth expiration reported by a streamed failure event fails the turn instead of returning partial text. + /// + [Fact] + public async Task RunPrompt_should_fail_when_provider_stream_reports_expired_auth() + { + var workspacePath = CreateTemporaryWorkspace(); + using var serviceProvider = CreateRuntimeServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + var runtime = serviceProvider.GetRequiredService(); + var sessionStore = serviceProvider.GetRequiredService(); + + var act = async () => await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "provider auth expires mid-stream", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "stub-provider", + ["model"] = "stub-model" + }), + CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Kind.Should().Be(ProviderFailureKind.AuthenticationUnavailable); + exception.Which.Message.Should().Contain("401"); + + var latestSession = await sessionStore.GetLatestAsync(workspacePath, CancellationToken.None); + latestSession.Should().NotBeNull(); + latestSession!.State.Should().Be(SessionLifecycleState.Failed); + } + /// /// Ensures unauthenticated providers fail explicitly instead of returning placeholder content. /// @@ -200,6 +278,18 @@ public Task GetStatusAsync(string providerName, CancellationToken ca => Task.FromResult(new AuthStatus("stub-subject", false, providerName, null, null, ["api"])); } + private sealed class ExpiredAuthFlowService : IAuthFlowService + { + public Task GetStatusAsync(string providerName, CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus( + "stub-subject", + true, + providerName, + null, + DateTimeOffset.UtcNow.AddMinutes(-1), + ["api"])); + } + private sealed class StubModelProviderResolver : IModelProviderResolver { public IModelProvider Resolve(string providerName) => new StubModelProvider(); @@ -210,6 +300,16 @@ private sealed class ThrowingModelProviderResolver : IModelProviderResolver public IModelProvider Resolve(string providerName) => new ThrowingModelProvider(); } + private sealed class FailIfInvokedModelProviderResolver : IModelProviderResolver + { + public IModelProvider Resolve(string providerName) => new FailIfInvokedModelProvider(); + } + + private sealed class AuthFailedEventModelProviderResolver : IModelProviderResolver + { + public IModelProvider Resolve(string providerName) => new AuthFailedEventModelProvider(); + } + private sealed class StubModelProvider : IModelProvider { public string ProviderName => "stub-provider"; @@ -249,4 +349,40 @@ private static async IAsyncEnumerable ThrowAsync() #pragma warning restore CS0162 } } + + private sealed class FailIfInvokedModelProvider : IModelProvider + { + public string ProviderName => "stub-provider"; + + public Task GetAuthStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus("stub-subject", true, ProviderName, null, null, ["api"])); + + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + => throw new InvalidOperationException("The provider stream should not start when auth is expired."); + } + + private sealed class AuthFailedEventModelProvider : IModelProvider + { + public string ProviderName => "stub-provider"; + + public Task GetAuthStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus("stub-subject", true, ProviderName, null, null, ["api"])); + + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + => Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request))); + + private static async IAsyncEnumerable StreamEventsAsync(ProviderRequest request) + { + yield return new ProviderEvent("provider-event-auth-1", request.Id, "delta", BaseTimestampUtc.AddMilliseconds(1), "partial", false, null); + await Task.Yield(); + yield return new ProviderEvent( + "provider-event-auth-2", + request.Id, + "failed", + BaseTimestampUtc.AddMilliseconds(2), + "HTTP 401 Unauthorized: token expired during streamed response.", + true, + null); + } + } } diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ProviderStreamAdapterTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ProviderStreamAdapterTests.cs index cbec5ae..8908053 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/ProviderStreamAdapterTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/ProviderStreamAdapterTests.cs @@ -1,9 +1,11 @@ using System.Globalization; +using System.Text.Json; using Anthropic.Models.Messages; using FluentAssertions; using Microsoft.Extensions.AI; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Providers.Internal; +using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.UnitTests.Providers; @@ -75,6 +77,76 @@ async IAsyncEnumerable Stream() events[^1].Usage!.CachedInputTokens.Should().Be(1); } + [Fact] + public async Task OpenAi_meai_adapter_emits_failed_event_when_sse_data_is_malformed() + { + var clock = new FixedSystemClock(); + + async IAsyncEnumerable Stream() + { + await Task.Yield(); + throw new JsonException("Malformed SSE data payload."); +#pragma warning disable CS0162 + yield return new ChatResponseUpdate(ChatRole.Assistant, []); +#pragma warning restore CS0162 + } + + var events = new List(); + await foreach (var e in OpenAiMeaiStreamAdapter.AdaptAsync(Stream(), "req-openai-bad-sse", clock, CancellationToken.None)) + { + events.Add(e); + } + + events.Should().ContainSingle(); + events[0].Kind.Should().Be("failed"); + events[0].IsTerminal.Should().BeTrue(); + events[0].Content.Should().Contain("Malformed SSE data payload"); + } + + [Fact] + public async Task OpenAi_meai_adapter_preserves_cancellation_when_stream_reports_transport_error_after_cancel() + { + var clock = new FixedSystemClock(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + async IAsyncEnumerable Stream() + { + await Task.Yield(); + throw new IOException("socket closed while reading SSE data."); +#pragma warning disable CS0162 + yield return new ChatResponseUpdate(ChatRole.Assistant, []); +#pragma warning restore CS0162 + } + + var act = async () => + { + await foreach (var _ in OpenAiMeaiStreamAdapter.AdaptAsync(Stream(), "req-openai-cancel", clock, cts.Token)) + { + } + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public void ProviderStreamFailureClassifier_preserves_reflected_http_status_for_auth_failures() + { + var exception = new ProviderStatusException(System.Net.HttpStatusCode.Unauthorized, "token expired"); + var failedEvent = new ProviderEvent( + "provider-event-auth", + "req-auth", + "failed", + DateTimeOffset.UtcNow, + ProviderStreamFailureClassifier.Describe(exception), + true, + null); + + ProviderStreamFailureClassifier.IsAuthenticationFailure(exception).Should().BeTrue(); + failedEvent.Content.Should().StartWith("HTTP 401 Unauthorized:"); + ProviderStreamFailureClassifier.ClassifyFailedEvent(failedEvent).Should().Be(ProviderFailureKind.AuthenticationUnavailable); + } + [Fact] public async Task Anthropic_sdk_adapter_maps_content_block_deltas_and_stop() { @@ -114,8 +186,65 @@ async IAsyncEnumerable Stream() events[^1].IsTerminal.Should().BeTrue(); } + [Fact] + public async Task Anthropic_sdk_adapter_emits_failed_event_when_sse_data_is_malformed() + { + var clock = new FixedSystemClock(); + + async IAsyncEnumerable Stream() + { + await Task.Yield(); + throw new JsonException("Malformed Anthropic SSE data payload."); +#pragma warning disable CS0162 + yield return new RawMessageStreamEvent(new RawMessageStopEvent(), default); +#pragma warning restore CS0162 + } + + var events = new List(); + await foreach (var e in AnthropicSdkStreamAdapter.AdaptAsync(Stream(), "req-anthropic-bad-sse", clock, CancellationToken.None)) + { + events.Add(e); + } + + events.Should().ContainSingle(); + events[0].Kind.Should().Be("failed"); + events[0].IsTerminal.Should().BeTrue(); + events[0].Content.Should().Contain("Malformed Anthropic SSE data payload"); + } + + [Fact] + public async Task Anthropic_sdk_adapter_preserves_cancellation_when_stream_reports_transport_error_after_cancel() + { + var clock = new FixedSystemClock(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + async IAsyncEnumerable Stream() + { + await Task.Yield(); + throw new IOException("socket closed while reading Anthropic SSE data."); +#pragma warning disable CS0162 + yield return new RawMessageStreamEvent(new RawMessageStopEvent(), default); +#pragma warning restore CS0162 + } + + var act = async () => + { + await foreach (var _ in AnthropicSdkStreamAdapter.AdaptAsync(Stream(), "req-anthropic-cancel", clock, cts.Token)) + { + } + }; + + await act.Should().ThrowAsync(); + } + private sealed class FixedSystemClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-04-06T00:00:00Z", CultureInfo.InvariantCulture); } + + private sealed class ProviderStatusException(System.Net.HttpStatusCode statusCode, string message) : Exception(message) + { + public System.Net.HttpStatusCode StatusCode { get; } = statusCode; + } } From 2dac20572a1553c5c576446a996c4fa8bc0b7847 Mon Sep 17 00:00:00 2001 From: telli Date: Sat, 2 May 2026 12:33:50 -0700 Subject: [PATCH 2/3] fix anthropic stream start logging --- src/SharpClaw.Code.Providers/AnthropicProvider.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SharpClaw.Code.Providers/AnthropicProvider.cs b/src/SharpClaw.Code.Providers/AnthropicProvider.cs index 1a3bd86..ab214b5 100644 --- a/src/SharpClaw.Code.Providers/AnthropicProvider.cs +++ b/src/SharpClaw.Code.Providers/AnthropicProvider.cs @@ -85,7 +85,7 @@ public Task StartStreamAsync(ProviderRequest request, Canc parameters = parameters with { System = systemPrompt }; } - logger.LogInformation("Started Anthropic SDK stream for request {RequestId}.", request.Id); + logger.LogInformation("Starting Anthropic SDK stream for request {RequestId}.", request.Id); IAsyncEnumerable stream; try @@ -106,6 +106,8 @@ public Task StartStreamAsync(ProviderRequest request, Canc exception); } + logger.LogInformation("Started Anthropic SDK stream for request {RequestId}.", request.Id); + return Task.FromResult(new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken))); } From 3c040f219874f3338e29215dbfded06b7077d0b6 Mon Sep 17 00:00:00 2001 From: telli Date: Sat, 2 May 2026 12:35:37 -0700 Subject: [PATCH 3/3] fix json renderer test isolation --- .../Rendering/JsonOutputRenderer.cs | 13 +++-- .../Smoke/JsonOutputRendererTests.cs | 54 +++++++------------ 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/src/SharpClaw.Code.Cli/Rendering/JsonOutputRenderer.cs b/src/SharpClaw.Code.Cli/Rendering/JsonOutputRenderer.cs index 09eda0e..7204359 100644 --- a/src/SharpClaw.Code.Cli/Rendering/JsonOutputRenderer.cs +++ b/src/SharpClaw.Code.Cli/Rendering/JsonOutputRenderer.cs @@ -12,9 +12,13 @@ namespace SharpClaw.Code.Cli.Rendering; /// /// Renders command and prompt results as JSON. /// -public sealed class JsonOutputRenderer(ILogger? logger = null) : IOutputRenderer +public sealed class JsonOutputRenderer( + ILogger? logger = null, + TextWriter? outputWriter = null) : IOutputRenderer { private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly TextWriter? _outputWriter = outputWriter; + /// public OutputFormat Format => OutputFormat.Json; @@ -47,15 +51,18 @@ public Task RenderCommandResultAsync(CommandResult result, CancellationToken can dataRaw), JsonOutputJsonContext.Default.JsonCommandEnvelope); - return Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken); + return WriteLineAsync(json, cancellationToken); } /// public Task RenderTurnExecutionResultAsync(TurnExecutionResult result, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(result, ProtocolJsonContext.Default.TurnExecutionResult); - return Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken); + return WriteLineAsync(json, cancellationToken); } + + private Task WriteLineAsync(string json, CancellationToken cancellationToken) + => (_outputWriter ?? Console.Out).WriteLineAsync(json.AsMemory(), cancellationToken); } internal sealed record JsonCommandEnvelope( diff --git a/tests/SharpClaw.Code.IntegrationTests/Smoke/JsonOutputRendererTests.cs b/tests/SharpClaw.Code.IntegrationTests/Smoke/JsonOutputRendererTests.cs index 465b8f1..563f1b9 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Smoke/JsonOutputRendererTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Smoke/JsonOutputRendererTests.cs @@ -17,26 +17,17 @@ public sealed class JsonOutputRendererTests [Fact] public async Task RenderCommandResultAsync_should_wrap_data_payload_in_stable_envelope() { - var renderer = new JsonOutputRenderer(); using var writer = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(writer); + var renderer = new JsonOutputRenderer(outputWriter: writer); - try - { - await renderer.RenderCommandResultAsync( - new CommandResult( - Succeeded: true, - ExitCode: 0, - OutputFormat: OutputFormat.Text, - Message: "ok", - DataJson: """{"version":"1.2.3"}"""), - CancellationToken.None); - } - finally - { - Console.SetOut(originalOut); - } + await renderer.RenderCommandResultAsync( + new CommandResult( + Succeeded: true, + ExitCode: 0, + OutputFormat: OutputFormat.Text, + Message: "ok", + DataJson: """{"version":"1.2.3"}"""), + CancellationToken.None); using var document = JsonDocument.Parse(writer.ToString()); var root = document.RootElement; @@ -54,26 +45,17 @@ await renderer.RenderCommandResultAsync( [Fact] public async Task RenderCommandResultAsync_should_fall_back_to_data_raw_for_invalid_payloads() { - var renderer = new JsonOutputRenderer(); using var writer = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(writer); + var renderer = new JsonOutputRenderer(outputWriter: writer); - try - { - await renderer.RenderCommandResultAsync( - new CommandResult( - Succeeded: false, - ExitCode: 1, - OutputFormat: OutputFormat.Text, - Message: "bad", - DataJson: "{not-json"), - CancellationToken.None); - } - finally - { - Console.SetOut(originalOut); - } + await renderer.RenderCommandResultAsync( + new CommandResult( + Succeeded: false, + ExitCode: 1, + OutputFormat: OutputFormat.Text, + Message: "bad", + DataJson: "{not-json"), + CancellationToken.None); using var document = JsonDocument.Parse(writer.ToString()); var root = document.RootElement;