-
Notifications
You must be signed in to change notification settings - Fork 863
Add IHostedConversationClient abstraction for hosted conversation lifecycle management #7393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rogerbarreto
wants to merge
7
commits into
dotnet:main
Choose a base branch
from
rogerbarreto:proposal-hostedconversationclient
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5f0477e
Add IHostedConversationClient abstraction for hosted conversation lif…
rogerbarreto 8a3d2e8
Address PR review feedback from @stephentoub and @qubitron
rogerbarreto 74ebbae
Move HostedConversation abstractions to Conversations folder
rogerbarreto 5629490
Merge branch 'main' into proposal-hostedconversationclient
rogerbarreto 9c8f7e1
Address Copilot reviewer feedback
rogerbarreto b01d3da
Add ListConversationsAsync to IHostedConversationClient
rogerbarreto 777df1e
Update API baselines for IHostedConversationClient types
rogerbarreto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| # IHostedConversationClient — Provider Mapping Report | ||
|
|
||
| ## Overview | ||
|
|
||
| `IHostedConversationClient` is an abstraction for managing server-side (hosted) conversation state across AI providers. It provides a common interface for creating, retrieving, deleting, and managing messages within persistent conversations, decoupling application code from provider-specific conversation/thread/session APIs. Each provider maps these operations to its native primitives, with escape hatches (`RawRepresentation`, `RawRepresentationFactory`, `AdditionalProperties`) for accessing provider-specific features. | ||
|
|
||
| ## Interface Operations | ||
|
|
||
| | Operation | Description | Return Type | | ||
| |-----------|-------------|-------------| | ||
| | `CreateAsync` | Creates a new hosted conversation | `HostedConversation` | | ||
| | `GetAsync` | Retrieves conversation by ID | `HostedConversation` | | ||
| | `DeleteAsync` | Deletes a conversation | `void` (Task) | | ||
| | `AddMessagesAsync` | Adds messages to a conversation | `void` (Task) | | ||
| | `GetMessagesAsync` | Lists messages in a conversation | `IAsyncEnumerable<ChatMessage>` | | ||
| | `ListConversationsAsync` | Lists all conversations | `IAsyncEnumerable<HostedConversation>` | | ||
|
|
||
| ## Provider Mapping | ||
|
|
||
| ### OpenAI (Implemented) | ||
|
|
||
| - Maps to `ConversationClient` in `OpenAI.Conversations` namespace | ||
| - Full CRUD support via protocol-level APIs | ||
| - `RawRepresentation` set to `ClientResult` objects | ||
| - ConversationId integrates with `ChatOptions.ConversationId` for inference via `OpenAIResponsesChatClient` | ||
| - Metadata limited to 16 key-value pairs (max 64 char keys, 512 char values) | ||
|
|
||
| ### Azure AI Foundry | ||
|
|
||
| - **Azure Foundry v2** uses the OpenAI Responses API directly, so the OpenAI `IHostedConversationClient` implementation works for Azure Foundry v2 without a separate adapter | ||
| - The deprecated v1 Agent Service SDK mapped to Thread/Message APIs (`threads.create()`, `threads.get()`, `threads.delete()`, `messages.create()`, `messages.list()`), but this is no longer the recommended path | ||
| - **Gaps**: Agent-specific concepts (Runs, Agents) are not in our abstraction; use `AdditionalProperties` for agent-specific metadata | ||
|
|
||
| ### AWS Bedrock | ||
|
|
||
| - Maps to Session Management APIs | ||
| - `CreateAsync` → `CreateSession` with optional encryption/metadata | ||
| - `GetAsync` → `GetSession` | ||
| - `DeleteAsync` → `DeleteSession` | ||
| - `AddMessagesAsync` → `PutInvocationStep` (different item model) | ||
| - `GetMessagesAsync` → `GetInvocationSteps` (requires translation) | ||
| - **Gaps**: Session status (ACTIVE/EXPIRED/ENDED) not in abstraction; encryption config is provider-specific; use `AdditionalProperties` or `RawRepresentationFactory` | ||
|
|
||
| ### Google Gemini | ||
|
|
||
| - Maps to Interactions API | ||
| - `CreateAsync` → `interactions.create()` (creates an interaction, not a "conversation" per se) | ||
| - `GetAsync` → `interactions.get()` | ||
| - `DeleteAsync` → `interactions.delete()` | ||
| - `AddMessagesAsync` → No direct equivalent; use `interactions.create()` with `previous_interaction_id` chain | ||
| - `GetMessagesAsync` → `interactions.get().outputs` (retrieves outputs, not full message history) | ||
| - **Gaps**: Interactions are individual turns, not conversation containers. AddMessages requires creating new interactions chained via `previous_interaction_id`. Provider adapter would need to manage this mapping. | ||
|
|
||
| ### Anthropic | ||
|
|
||
| - **No native conversation CRUD API** — requires local adapter | ||
| - Server-side features that CAN assist: | ||
| - **Prompt Caching** (`cache_control`): Stores KV cache of message prefixes (5min/1hr TTL). Adapter should auto-apply cache breakpoints. | ||
| - **Context Compaction** (beta): Server-side summarization when conversations exceed token threshold | ||
| - **Files API** (beta): Store documents server-side for reference across requests | ||
| - **Containers** (beta): Server-side execution state with reusable IDs | ||
| - Implementation approach: `LocalHostedConversationClient<TStore>` using local storage (in-memory, SQLite, Redis) with automatic prompt caching optimization | ||
| - **Gaps**: All operations are simulated client-side. No server-side conversation persistence. | ||
|
|
||
| ### Ollama / Local Models | ||
|
|
||
| - **No server-side state** at all | ||
| - Implementation: Same local adapter pattern as Anthropic but without prompt caching optimization | ||
| - **Gaps**: Same as Anthropic — entirely client-side simulation | ||
|
|
||
| ## Escape Hatches for Provider-Specific Features | ||
|
|
||
| ### RawRepresentation | ||
|
|
||
| Every `HostedConversation` response carries `RawRepresentation` (the underlying provider object). This gives access to 100% of provider functionality: | ||
|
|
||
| ```csharp | ||
| var conversation = await client.CreateAsync(); | ||
| var openAIResult = (ClientResult)conversation.RawRepresentation; // Access any OpenAI-specific data | ||
| ``` | ||
|
|
||
| ### RawRepresentationFactory | ||
|
|
||
| `HostedConversationCreationOptions.RawRepresentationFactory` allows passing provider-specific creation options: | ||
|
|
||
| ```csharp | ||
| var options = new HostedConversationClientOptions | ||
| { | ||
| RawRepresentationFactory = client => new ConversationCreationOptions | ||
| { | ||
| // Any provider-specific settings | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| ### AdditionalProperties | ||
|
|
||
| `HostedConversation.AdditionalProperties` and `HostedConversationClientOptions.AdditionalProperties` carry provider-specific data that doesn't fit the common abstraction. | ||
|
|
||
|
rogerbarreto marked this conversation as resolved.
|
||
| ## Feature Coverage Matrix | ||
|
|
||
| | Feature | OpenAI | Azure | Bedrock | Gemini | Anthropic | Ollama | | ||
| |---------|--------|-------|---------|--------|-----------|--------| | ||
| | Create | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | | ||
| | Get | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | | ||
| | Delete | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | | ||
| | AddMessages | ✅ Native | ✅ Native | ⚠️ Translated | ⚠️ Chained | ⚠️ Local | ⚠️ Local | | ||
| | GetMessages | ✅ Native | ✅ Native | ⚠️ Translated | ⚠️ Partial | ⚠️ Local | ⚠️ Local | | ||
| | ListConversations | ❌ Not supported | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | | ||
| | Metadata | ✅ 16 KV | ✅ | ✅ | ✅ | ⚠️ Local | ⚠️ Local | | ||
| | RawRepresentation | ✅ ClientResult | ✅ AgentThread | ✅ Session | ✅ Interaction | N/A | N/A | | ||
|
|
||
| Legend: ✅ = Direct mapping, ⚠️ = Requires translation/local adapter | ||
105 changes: 105 additions & 0 deletions
105
.../Microsoft.Extensions.AI.Abstractions/Conversations/DelegatingHostedConversationClient.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
| using Microsoft.Shared.Diagnostics; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary> | ||
| /// Provides an optional base class for an <see cref="IHostedConversationClient"/> that passes through calls to another instance. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This is recommended as a base type when building clients that can be chained around an underlying <see cref="IHostedConversationClient"/>. | ||
| /// The default implementation simply passes each call to the inner client instance. | ||
| /// </remarks> | ||
| [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public class DelegatingHostedConversationClient : IHostedConversationClient | ||
| { | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="DelegatingHostedConversationClient"/> class. | ||
| /// </summary> | ||
| /// <param name="innerClient">The wrapped client instance.</param> | ||
| /// <exception cref="ArgumentNullException"><paramref name="innerClient"/> is <see langword="null"/>.</exception> | ||
| protected DelegatingHostedConversationClient(IHostedConversationClient innerClient) | ||
| { | ||
| InnerClient = Throw.IfNull(innerClient); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public void Dispose() | ||
| { | ||
| Dispose(disposing: true); | ||
| GC.SuppressFinalize(this); | ||
| } | ||
|
|
||
| /// <summary>Gets the inner <see cref="IHostedConversationClient" />.</summary> | ||
| protected IHostedConversationClient InnerClient { get; } | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual Task<HostedConversation> CreateAsync( | ||
| HostedConversationClientOptions? options = null, | ||
| CancellationToken cancellationToken = default) => | ||
| InnerClient.CreateAsync(options, cancellationToken); | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual Task<HostedConversation> GetAsync( | ||
| string conversationId, | ||
| HostedConversationClientOptions? options = null, | ||
| CancellationToken cancellationToken = default) => | ||
| InnerClient.GetAsync(conversationId, options, cancellationToken); | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual Task DeleteAsync( | ||
| string conversationId, | ||
| HostedConversationClientOptions? options = null, | ||
| CancellationToken cancellationToken = default) => | ||
| InnerClient.DeleteAsync(conversationId, options, cancellationToken); | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual Task AddMessagesAsync( | ||
| string conversationId, | ||
| IEnumerable<ChatMessage> messages, | ||
| HostedConversationClientOptions? options = null, | ||
| CancellationToken cancellationToken = default) => | ||
| InnerClient.AddMessagesAsync(conversationId, messages, options, cancellationToken); | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual IAsyncEnumerable<ChatMessage> GetMessagesAsync( | ||
| string conversationId, | ||
| HostedConversationClientOptions? options = null, | ||
| CancellationToken cancellationToken = default) => | ||
| InnerClient.GetMessagesAsync(conversationId, options, cancellationToken); | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual IAsyncEnumerable<HostedConversation> ListConversationsAsync( | ||
| HostedConversationClientOptions? options = null, | ||
| CancellationToken cancellationToken = default) => | ||
| InnerClient.ListConversationsAsync(options, cancellationToken); | ||
|
|
||
| /// <inheritdoc /> | ||
| public virtual object? GetService(Type serviceType, object? serviceKey = null) | ||
| { | ||
| _ = Throw.IfNull(serviceType); | ||
|
|
||
| // If the key is non-null, we don't know what it means so pass through to the inner service. | ||
| return | ||
| serviceKey is null && serviceType.IsInstanceOfType(this) ? this : | ||
| InnerClient.GetService(serviceType, serviceKey); | ||
| } | ||
|
|
||
| /// <summary>Provides a mechanism for releasing unmanaged resources.</summary> | ||
| /// <param name="disposing"><see langword="true"/> if being called from <see cref="Dispose()"/>; otherwise, <see langword="false"/>.</param> | ||
| protected virtual void Dispose(bool disposing) | ||
| { | ||
| if (disposing) | ||
| { | ||
| InnerClient.Dispose(); | ||
| } | ||
| } | ||
| } |
32 changes: 32 additions & 0 deletions
32
src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversation.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Text.Json.Serialization; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary>Represents a hosted conversation.</summary> | ||
| [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public class HostedConversation | ||
| { | ||
| /// <summary>Gets or sets the conversation identifier.</summary> | ||
| public string? ConversationId { get; set; } | ||
|
|
||
| /// <summary>Gets or sets the creation timestamp.</summary> | ||
| public DateTimeOffset? CreatedAt { get; set; } | ||
|
|
||
| /// <summary>Gets or sets the raw representation of the conversation from the underlying provider.</summary> | ||
| /// <remarks> | ||
| /// If a <see cref="HostedConversation"/> is created to represent some underlying object from another object | ||
| /// model, this property can be used to store that original object. This can be useful for debugging or | ||
| /// for enabling a consumer to access the underlying object model if needed. | ||
| /// </remarks> | ||
| [JsonIgnore] | ||
| public object? RawRepresentation { get; set; } | ||
|
|
||
| /// <summary>Gets or sets any additional properties associated with the conversation.</summary> | ||
| public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } | ||
| } |
82 changes: 82 additions & 0 deletions
82
.../Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
| using Microsoft.Shared.Diagnostics; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary>Provides a collection of static methods for extending <see cref="IHostedConversationClient"/> instances.</summary> | ||
| [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public static class HostedConversationClientExtensions | ||
| { | ||
| /// <summary>Asks the <see cref="IHostedConversationClient"/> for an object of type <typeparamref name="TService"/>.</summary> | ||
| /// <typeparam name="TService">The type of the object to be retrieved.</typeparam> | ||
| /// <param name="client">The client.</param> | ||
| /// <param name="serviceKey">An optional key that can be used to help identify the target service.</param> | ||
| /// <returns>The found object, otherwise <see langword="null"/>.</returns> | ||
| /// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception> | ||
| /// <remarks> | ||
| /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="IHostedConversationClient"/>, | ||
| /// including itself or any services it might be wrapping. | ||
| /// </remarks> | ||
| public static TService? GetService<TService>(this IHostedConversationClient client, object? serviceKey = null) | ||
| { | ||
| _ = Throw.IfNull(client); | ||
|
|
||
| return client.GetService(typeof(TService), serviceKey) is TService service ? service : default; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Asks the <see cref="IHostedConversationClient"/> for an object of the specified type <paramref name="serviceType"/> | ||
| /// and throws an exception if one isn't available. | ||
| /// </summary> | ||
| /// <param name="client">The client.</param> | ||
| /// <param name="serviceType">The type of object being requested.</param> | ||
| /// <param name="serviceKey">An optional key that can be used to help identify the target service.</param> | ||
| /// <returns>The found object.</returns> | ||
| /// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception> | ||
| /// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception> | ||
| /// <exception cref="InvalidOperationException">No service of the requested type for the specified key is available.</exception> | ||
| /// <remarks> | ||
| /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the <see cref="IHostedConversationClient"/>, | ||
| /// including itself or any services it might be wrapping. | ||
| /// </remarks> | ||
| public static object GetRequiredService(this IHostedConversationClient client, Type serviceType, object? serviceKey = null) | ||
| { | ||
| _ = Throw.IfNull(client); | ||
| _ = Throw.IfNull(serviceType); | ||
|
|
||
| return | ||
| client.GetService(serviceType, serviceKey) ?? | ||
| throw Throw.CreateMissingServiceException(serviceType, serviceKey); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Asks the <see cref="IHostedConversationClient"/> for an object of type <typeparamref name="TService"/> | ||
| /// and throws an exception if one isn't available. | ||
| /// </summary> | ||
| /// <typeparam name="TService">The type of the object to be retrieved.</typeparam> | ||
| /// <param name="client">The client.</param> | ||
| /// <param name="serviceKey">An optional key that can be used to help identify the target service.</param> | ||
| /// <returns>The found object.</returns> | ||
| /// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception> | ||
| /// <exception cref="InvalidOperationException">No service of the requested type for the specified key is available.</exception> | ||
| /// <remarks> | ||
| /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the <see cref="IHostedConversationClient"/>, | ||
| /// including itself or any services it might be wrapping. | ||
| /// </remarks> | ||
| public static TService GetRequiredService<TService>(this IHostedConversationClient client, object? serviceKey = null) | ||
| { | ||
| _ = Throw.IfNull(client); | ||
|
|
||
| if (client.GetService(typeof(TService), serviceKey) is not TService service) | ||
| { | ||
| throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); | ||
| } | ||
|
|
||
| return service; | ||
| } | ||
| } |
35 changes: 35 additions & 0 deletions
35
...es/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientMetadata.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using Microsoft.Shared.DiagnosticIds; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary>Provides metadata about an <see cref="IHostedConversationClient"/>.</summary> | ||
| [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] | ||
| public class HostedConversationClientMetadata | ||
| { | ||
| /// <summary>Initializes a new instance of the <see cref="HostedConversationClientMetadata"/> class.</summary> | ||
| /// <param name="providerName"> | ||
| /// The name of the hosted conversation provider, if applicable. Where possible, this should map to the | ||
| /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. | ||
| /// </param> | ||
| /// <param name="providerUri">The URL for accessing the hosted conversation provider, if applicable.</param> | ||
| public HostedConversationClientMetadata(string? providerName = null, Uri? providerUri = null) | ||
| { | ||
| ProviderName = providerName; | ||
| ProviderUri = providerUri; | ||
| } | ||
|
|
||
| /// <summary>Gets the name of the hosted conversation provider.</summary> | ||
| /// <remarks> | ||
| /// Where possible, this maps to the appropriate name defined in the | ||
| /// OpenTelemetry Semantic Conventions for Generative AI systems. | ||
| /// </remarks> | ||
| public string? ProviderName { get; } | ||
|
|
||
| /// <summary>Gets the URL for accessing the hosted conversation provider.</summary> | ||
| public Uri? ProviderUri { get; } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.