Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions CSharpRepl.Services/Completion/AICompleteService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,51 @@ public async IAsyncEnumerable<string> CompleteAsync(IReadOnlyList<string> 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", " ");
}
}

/// <summary>
/// Renders a streaming-completion failure as a C# comment so it can be safely inserted into the prompt input.
/// </summary>
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}";
}
}
86 changes: 86 additions & 0 deletions Tests/CSharpRepl.Tests/AICompleteServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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<string>();
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<string>();
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)]
Expand Down Expand Up @@ -144,4 +205,29 @@ public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, Ch

public void Dispose() { }
}

private sealed class ThrowingChatClient(Exception toThrow, params string[] chunksBeforeThrow) : IChatClient
{
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> Stream();

private async IAsyncEnumerable<ChatResponseUpdate> Stream()
{
foreach (var chunk in chunksBeforeThrow)
{
yield return new ChatResponseUpdate(ChatRole.Assistant, chunk);
await Task.Yield();
}
await Task.Yield();
throw toThrow;
}

public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();

public object? GetService(Type serviceType, object? serviceKey = null) => null;

public void Dispose() { }
}
}
Loading