diff --git a/CSharpRepl.Services/Completion/AICompleteService.cs b/CSharpRepl.Services/Completion/AICompleteService.cs index e650dcc2..1fc4e6c2 100644 --- a/CSharpRepl.Services/Completion/AICompleteService.cs +++ b/CSharpRepl.Services/Completion/AICompleteService.cs @@ -100,14 +100,51 @@ public async IAsyncEnumerable CompleteAsync(IReadOnlyList submis } messages.Add(new(ChatRole.User, code)); - await foreach (var update in client.GetStreamingResponseAsync(messages, cancellationToken: cancellationToken)) + await using var updates = client.GetStreamingResponseAsync(messages, cancellationToken: cancellationToken).GetAsyncEnumerator(cancellationToken); + + while (true) // while(true) because MoveNextAsync can throw (due to API errors), and we can't have a yield return inside a try block that has a catch clause (CS1626/CS1631) { - var content = update.Text; - if (string.IsNullOrEmpty(content)) + string? content; + string? error = null; + try + { + if (!await updates.MoveNextAsync()) + { + break; + } + content = updates.Current.Text; + } + catch (OperationCanceledException) + { + // The user cancelled; stop quietly without inserting anything. + yield break; + } + catch (Exception ex) + { + // some exception like an HTTP 429 when the AI provider's quota is exceeded + content = null; + error = FormatError(ex); + } + + if (error is not null) { - continue; + yield return error; + yield break; + } + + if (!string.IsNullOrEmpty(content)) + { + yield return content.Replace("\t", " "); } - yield return content.Replace("\t", " "); } } + + /// + /// Renders a streaming-completion failure as a C# comment so it can be safely inserted into the prompt input. + /// + private static string FormatError(Exception ex) + { + var message = string.Join(' ', ex.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + return $"{Environment.NewLine}// AI completion failed: {message}"; + } } diff --git a/Tests/CSharpRepl.Tests/AICompleteServiceTests.cs b/Tests/CSharpRepl.Tests/AICompleteServiceTests.cs index 240a2b77..f33e0d3f 100644 --- a/Tests/CSharpRepl.Tests/AICompleteServiceTests.cs +++ b/Tests/CSharpRepl.Tests/AICompleteServiceTests.cs @@ -77,6 +77,67 @@ public async Task CompleteAsync_WithInjectedChatClient_StreamsChunksTabExpandedA Assert.Equal(new[] { "var x", " = 1;" }, results); } + [Fact] + public async Task CompleteAsync_WhenProviderThrows_YieldsErrorCommentInsteadOfThrowing() + { + // A provider failure (e.g. HTTP 429 quota exceeded) must not crash the REPL: it surfaces as a C# comment. + var fake = new ThrowingChatClient(new InvalidOperationException("HTTP 429 (insufficient_quota)\n\nYou exceeded your current quota.")); + var service = new AICompleteService( + new AICompletionConfiguration(apiKey: "key", endpoint: null, model: "any", prompt: "p", historyCount: 5), + fake); + + var results = new List(); + await foreach (var chunk in service.CompleteAsync([], "var ", caret: 4, cancellationToken: TestContext.Current.CancellationToken)) + { + results.Add(chunk); + } + + var combined = string.Concat(results); + Assert.Contains("// AI completion failed:", combined); + Assert.Contains("HTTP 429 (insufficient_quota)", combined); + Assert.DoesNotContain("\n//", combined.TrimStart()); // multi-line message collapsed to a single comment line + } + + [Fact] + public async Task CompleteAsync_WhenProviderThrowsMidStream_YieldsPartialOutputThenErrorComment() + { + var fake = new ThrowingChatClient(new InvalidOperationException("boom"), "var x", "= 1;"); + var service = new AICompleteService( + new AICompletionConfiguration(apiKey: "key", endpoint: null, model: "any", prompt: "p", historyCount: 5), + fake); + + var results = new List(); + await foreach (var chunk in service.CompleteAsync([], "var ", caret: 4, cancellationToken: TestContext.Current.CancellationToken)) + { + results.Add(chunk); + } + + Assert.Equal("var x", results[0]); + Assert.Equal("= 1;", results[1]); + Assert.EndsWith("// AI completion failed: boom", results[^1]); + } + + [Fact] + public async Task CompleteAsync_WhenCancelled_YieldsNothing() + { + // Cancellation is a normal stop, not an error: it must not insert an error comment. + var fake = new FakeChatClient("var x", "= 1;"); + var service = new AICompleteService( + new AICompletionConfiguration(apiKey: "key", endpoint: null, model: "any", prompt: "p", historyCount: 5), + fake); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var results = new List(); + await foreach (var chunk in service.CompleteAsync([], "var ", caret: 4, cancellationToken: cts.Token)) + { + results.Add(chunk); + } + + Assert.Empty(results); + } + [Theory] [InlineData(null, null, null)] // unspecified provider defaults to OpenAI's default model/endpoint [InlineData("openai", null, null)] @@ -144,4 +205,29 @@ public Task GetResponseAsync(IEnumerable messages, Ch public void Dispose() { } } + + private sealed class ThrowingChatClient(Exception toThrow, params string[] chunksBeforeThrow) : IChatClient + { + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Stream(); + + private async IAsyncEnumerable Stream() + { + foreach (var chunk in chunksBeforeThrow) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, chunk); + await Task.Yield(); + } + await Task.Yield(); + throw toThrow; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } }