File: Interaction\ExtensionInteractionService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Threading.Channels;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Rendering;
 
namespace Aspire.Cli.Interaction;
 
internal interface IExtensionInteractionService : IInteractionService
{
    IExtensionBackchannel Backchannel { get; }
    Task FlushAsync(CancellationToken cancellationToken = default);
    void OpenEditor(string projectPath);
    void LogMessage(LogLevel logLevel, string message);
    Task LaunchAppHostAsync(string projectFile, List<string> arguments, List<EnvVar> environment, bool debug);
    void DisplayDashboardUrls(DashboardUrlsState dashboardUrls);
    void NotifyAppHostStartupCompleted();
    void DisplayConsolePlainText(string message);
    Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, DebugSessionOptions? options = null);
    void WriteDebugSessionMessage(string message, bool stdout, string? textStyle);
    void WriteAppHostLogEntry(ExtensionAppHostLogEntry entry);
    void ConsoleDisplaySubtleMessage(string message, bool allowMarkup = false);
}
 
internal class ExtensionInteractionService : IExtensionInteractionService, IDisposable
{
    private readonly ConsoleInteractionService _consoleInteractionService;
    private readonly bool _extensionPromptEnabled;
    private readonly CancellationTokenSource _cts = new();
    private readonly CancellationToken _cancellationToken;
    private readonly Channel<Func<Task>> _extensionTaskChannel;
    private readonly ILogger<ExtensionInteractionService> _logger;
 
    /// <summary>
    /// The background pump task that processes queued extension operations.
    /// Completes when the channel is completed and/or the token is cancelled.
    /// </summary>
    internal Task PumpTask { get; }
 
    public IExtensionBackchannel Backchannel { get; }
 
    public ExtensionInteractionService(ConsoleInteractionService consoleInteractionService, IExtensionBackchannel backchannel, bool extensionPromptEnabled, ILogger<ExtensionInteractionService> logger)
    {
        _consoleInteractionService = consoleInteractionService;
        Backchannel = backchannel;
        _extensionPromptEnabled = extensionPromptEnabled;
        _cancellationToken = _cts.Token;
        _logger = logger;
        _extensionTaskChannel = Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions
        {
            SingleReader = true,
            SingleWriter = true
        });
 
        // Use CancellationToken.None here to avoid the pump task being cancelled before it is scheduled.
        // Code in the pump task itself will observe the cancellation token to exit gracefully when the service is disposed.
        PumpTask = Task.Run(ProcessExtensionTaskChannelAsync, CancellationToken.None);
    }
 
    public async Task FlushAsync(CancellationToken cancellationToken = default)
    {
        var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        // Queue a sentinel after all pending extension operations so callers can wait until
        // debug-console output has reached the extension before the CLI process exits.
        await _extensionTaskChannel.Writer.WriteAsync(() =>
        {
            completionSource.TrySetResult();
            return Task.CompletedTask;
        }, cancellationToken).ConfigureAwait(false);
 
        await completionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action, KnownEmoji? emoji = null, bool allowMarkup = false)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(StringUtils.RemoveMarkup(statusText), _cancellationToken));
        Debug.Assert(result);
 
        try
        {
            return await _consoleInteractionService.ShowStatusAsync(statusText, action, emoji, allowMarkup).ConfigureAwait(false);
        }
        finally
        {
            // Clear the IDE status indicator even if the action threw, to avoid leaving it spinning indefinitely.
            result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(null, _cancellationToken));
            Debug.Assert(result);
        }
    }
 
    public async Task<T> ShowDynamicStatusAsync<T>(string initialStatusText, Func<Action<string>, Task<T>> action, KnownEmoji? emoji = null)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(StringUtils.RemoveMarkup(initialStatusText), _cancellationToken));
        Debug.Assert(result);
 
        try
        {
            return await _consoleInteractionService.ShowDynamicStatusAsync(
                initialStatusText,
                updateStatus => action(statusText =>
                {
                    var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(StringUtils.RemoveMarkup(statusText), _cancellationToken));
                    Debug.Assert(result);
                    updateStatus(statusText);
                }),
                emoji).ConfigureAwait(false);
        }
        finally
        {
            result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(null, _cancellationToken));
            Debug.Assert(result);
        }
    }
 
    public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(StringUtils.RemoveMarkup(statusText), _cancellationToken));
        Debug.Assert(result);
 
        try
        {
            _consoleInteractionService.ShowStatus(statusText, action, emoji, allowMarkup);
        }
        finally
        {
            // Clear the IDE status indicator even if the action threw, to avoid leaving it spinning indefinitely.
            result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.ShowStatusAsync(null, _cancellationToken));
            Debug.Assert(result);
        }
    }
 
    public async Task<string> PromptForStringAsync(string promptText, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, PromptBinding<string?>? binding = null, CancellationToken cancellationToken = default)
    {
        // Check binding first — if a CLI arg was explicitly provided, return it immediately
        // without prompting through either the extension or console path.
        var (wasProvided, value, _) = PromptBinding.Resolve(binding);
        if (wasProvided && value is not null)
        {
            _consoleInteractionService.ValidateResolvedStringValue(value, required, validator, binding!.SymbolDisplayName);
            return value;
        }
 
        if (_extensionPromptEnabled)
        {
            var tcs = new TaskCompletionSource<string>();
 
            await _extensionTaskChannel.Writer.WriteAsync(async () =>
            {
                try
                {
                    string result;
                    if (isSecret)
                    {
                        // Check if extension supports the new secret prompts capability
                        var hasSecretPromptsCapability = await Backchannel.HasCapabilityAsync(KnownCapabilities.SecretPrompts, _cancellationToken).ConfigureAwait(false);
 
                        if (hasSecretPromptsCapability)
                        {
                            // Use the new dedicated secret prompt method (no default value for secrets)
                            result = await Backchannel.PromptForSecretStringAsync(StringUtils.RemoveMarkup(promptText), validator, required, _cancellationToken).ConfigureAwait(false);
                        }
                        else
                        {
                            // Fallback to regular prompt for older extension versions
                            result = await Backchannel.PromptForStringAsync(StringUtils.RemoveMarkup(promptText), binding?.DefaultValue, validator, required, _cancellationToken).ConfigureAwait(false);
                        }
                    }
                    else
                    {
                        result = await Backchannel.PromptForStringAsync(StringUtils.RemoveMarkup(promptText), binding?.DefaultValue, validator, required, _cancellationToken).ConfigureAwait(false);
                    }
 
                    tcs.SetResult(result);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                }
            }, cancellationToken).ConfigureAwait(false);
 
            return await tcs.Task.ConfigureAwait(false);
        }
        else
        {
            return await _consoleInteractionService.PromptForStringAsync(promptText, validator, isSecret, required, binding, cancellationToken).ConfigureAwait(false);
        }
    }
 
    public async Task<string> PromptForFilePathAsync(string promptText, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, PromptBinding<string?>? binding = null, bool retryOnValidationFailure = false, CancellationToken cancellationToken = default)
    {
        var (wasProvided, value, _) = PromptBinding.Resolve(binding);
        if (wasProvided && value is not null)
        {
            _consoleInteractionService.ValidateResolvedStringValue(value, required, validator, binding!.SymbolDisplayName);
            return value;
        }
 
        if (_extensionPromptEnabled)
        {
            var hasFilePickersCapability = await Backchannel.HasCapabilityAsync(KnownCapabilities.FilePickers, _cancellationToken).ConfigureAwait(false);
 
            if (hasFilePickersCapability)
            {
                while (true)
                {
                    var tcs = new TaskCompletionSource<string?>();
 
                    await _extensionTaskChannel.Writer.WriteAsync(async () =>
                    {
                        try
                        {
                            var result = await Backchannel.PromptForFilePathAsync(StringUtils.RemoveMarkup(promptText), binding?.DefaultValue, directory, _cancellationToken).ConfigureAwait(false);
                            tcs.SetResult(result);
                        }
                        catch (Exception ex)
                        {
                            tcs.SetException(ex);
                        }
                    }, cancellationToken).ConfigureAwait(false);
 
                    var picked = await tcs.Task.ConfigureAwait(false);
 
                    if (picked is null)
                    {
                        throw new ExtensionOperationCanceledException(promptText);
                    }
 
                    if (validator is null)
                    {
                        return picked;
                    }
 
                    var validationResult = validator(picked);
                    if (validationResult.Successful)
                    {
                        return picked;
                    }
 
                    // VS Code file pickers can't show inline validation, so keep the wizard alive
                    // by displaying the error before reopening the picker.
                    var errorMessage = validationResult.Message ?? InteractionServiceStrings.InvalidSelection;
                    DisplayError(errorMessage);
 
                    if (!retryOnValidationFailure)
                    {
                        throw new InvalidOperationException(errorMessage);
                    }
                }
            }
 
            // Fall back to string prompt for older extensions without file picker support
            return await PromptForStringAsync(promptText, validator, isSecret: false, required, binding, cancellationToken).ConfigureAwait(false);
        }
 
        return await _consoleInteractionService.PromptForFilePathAsync(promptText, validator, directory, required, binding, retryOnValidationFailure, cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<bool> PromptConfirmAsync(string promptText, PromptBinding<bool>? binding = null, CancellationToken cancellationToken = default)
    {
        var (wasProvided, value, _) = PromptBinding.Resolve(binding);
        if (wasProvided)
        {
            return value;
        }
 
        if (_extensionPromptEnabled)
        {
            var tcs = new TaskCompletionSource<bool>();
 
            await _extensionTaskChannel.Writer.WriteAsync(async () =>
            {
                try
                {
                    var result = await Backchannel.ConfirmAsync(StringUtils.RemoveMarkup(promptText), binding?.DefaultValue ?? false, _cancellationToken).ConfigureAwait(false);
                    tcs.SetResult(result);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                    if (ex is not ExtensionOperationCanceledException)
                    {
                        DisplayError(ex.Message);
                    }
                }
            }, cancellationToken).ConfigureAwait(false);
 
            return await tcs.Task.ConfigureAwait(false);
        }
        else
        {
            return await _consoleInteractionService.PromptConfirmAsync(promptText, binding, cancellationToken);
        }
    }
 
    public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter,
        PromptBinding<string?>? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull
    {
        var (wasProvided, value, _) = PromptBinding.Resolve(binding);
        if (wasProvided && value is not null)
        {
            return _consoleInteractionService.MatchChoiceOrThrow(value, binding!, choices, choiceFormatter);
        }
 
        if (_extensionPromptEnabled)
        {
            var tcs = new TaskCompletionSource<T>();
 
            await _extensionTaskChannel.Writer.WriteAsync(async () =>
            {
                try
                {
                    var result = await Backchannel.PromptForSelectionAsync(StringUtils.RemoveMarkup(promptText), choices, choiceFormatter, _cancellationToken).ConfigureAwait(false);
                    tcs.SetResult(result);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                    if (ex is not ExtensionOperationCanceledException)
                    {
                        DisplayError(ex.Message);
                    }
                }
            }, cancellationToken).ConfigureAwait(false);
 
            return await tcs.Task.ConfigureAwait(false);
        }
        else
        {
            return await _consoleInteractionService.PromptForSelectionAsync(promptText, choices, choiceFormatter, binding, echoSelected, cancellationToken);
        }
    }
 
    public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter,
        IEnumerable<T>? preSelected = null, bool optional = false, PromptBinding<string?>? binding = null, bool echoSelected = true, IEnumerable<T>? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull
    {
        var (wasProvided, value, _) = PromptBinding.Resolve(binding);
        if (wasProvided && value is not null)
        {
            var validationChoices = bindingChoices ?? choices;
            return _consoleInteractionService.MatchChoicesOrThrow(value, binding!, validationChoices, choiceFormatter);
        }
 
        if (_extensionPromptEnabled)
        {
            var tcs = new TaskCompletionSource<IReadOnlyList<T>>();
 
            await _extensionTaskChannel.Writer.WriteAsync(async () =>
            {
                try
                {
                    // Note: The extension backchannel protocol does not yet support preSelected items.
                    // Pre-selected items are applied only when falling back to the console interaction service.
                    var result = await Backchannel.PromptForSelectionsAsync(StringUtils.RemoveMarkup(promptText), choices, choiceFormatter, _cancellationToken).ConfigureAwait(false);
                    tcs.SetResult(result);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                    if (ex is not ExtensionOperationCanceledException)
                    {
                        DisplayError(ex.Message);
                    }
                }
            }, cancellationToken).ConfigureAwait(false);
 
            return await tcs.Task.ConfigureAwait(false);
        }
        else
        {
            return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, bindingChoices, cancellationToken);
        }
    }
 
    public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingSdkVersion)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayIncompatibleVersionErrorAsync(ex.RequiredCapability, appHostHostingSdkVersion, _cancellationToken));
        Debug.Assert(result);
 
        return _consoleInteractionService.DisplayIncompatibleVersionError(ex, appHostHostingSdkVersion);
    }
 
    public void DisplayError(string errorMessage, bool allowMarkup = false)
    {
        // Serialize the local console write onto the same channel as the backchannel call so
        // it stays ordered with prior queued operations (e.g. DisplayLines). Otherwise the
        // synchronous Spectre write would land in the IDE debug console (via stdout/stderr
        // capture) before earlier asynchronous DisplayLines RPCs had flushed, producing
        // out-of-order output like an error message preceding the lines that explain it.
        var result = _extensionTaskChannel.Writer.TryWrite(async () =>
        {
            await Backchannel.DisplayErrorAsync(StringUtils.RemoveMarkup(errorMessage), _cancellationToken);
            _consoleInteractionService.DisplayError(errorMessage, allowMarkup);
        });
        Debug.Assert(result);
    }
 
    public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(async () =>
        {
            await Backchannel.DisplayMessageAsync(emoji.Name, StringUtils.RemoveMarkup(message), _cancellationToken);
            _consoleInteractionService.DisplayMessage(emoji, message, allowMarkup, consoleOverride);
        });
        Debug.Assert(result);
    }
 
    public void DisplaySuccess(string message, bool allowMarkup = false)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplaySuccessAsync(StringUtils.RemoveMarkup(message), _cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplaySuccess(message, allowMarkup);
    }
 
    public void DisplaySubtleMessage(string message, bool allowMarkup = false)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplaySubtleMessageAsync(StringUtils.RemoveMarkup(message), _cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplaySubtleMessage(message, allowMarkup);
    }
 
    public void ConsoleDisplaySubtleMessage(string message, bool allowMarkup = false)
    {
        _consoleInteractionService.DisplaySubtleMessage(message, allowMarkup);
    }
 
    public void DisplayDashboardUrls(DashboardUrlsState dashboardUrls)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayDashboardUrlsAsync(dashboardUrls, _cancellationToken));
        Debug.Assert(result);
    }
 
    public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
    {
        // Materialize so we can iterate twice without re-enumerating a possibly lazy/one-shot source.
        var materialized = lines as IReadOnlyCollection<(OutputLineStream Stream, string Line)> ?? lines.ToList();
 
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(materialized.Select(line => new DisplayLineState(
            line.Stream == OutputLineStream.StdOut ? "stdout" : "stderr",
            StringUtils.RemoveMarkup(line.Line))), _cancellationToken));
        Debug.Assert(result);
 
        // Intentionally do NOT also write to the local console here. Unlike most Display* methods
        // (whose backchannel sinks are distinct from the debug console — popups, status bar, log
        // channel, etc.), the extension's `displayLines` RPC routes the lines into the active
        // AppHost debug console. The CLI's stdout/stderr is also captured by the extension and
        // forwarded into that same debug console, so calling _consoleInteractionService.DisplayLines
        // here would surface every line twice.
    }
 
    public void DisplayCancellationMessage(string? message = null, ConsoleOutput? consoleOverride = null)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayCancellationMessageAsync(_cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplayCancellationMessage(message, consoleOverride);
    }
 
    public void DisplayEmptyLine()
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayEmptyLineAsync(_cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplayEmptyLine();
    }
 
    public void OpenEditor(string path)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.OpenEditorAsync(path, _cancellationToken));
        Debug.Assert(result);
    }
 
    public void DisplayPlainText(string text)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayPlainTextAsync(text, _cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplayPlainText(text);
    }
 
    public ConsoleOutput Console
    {
        get => _consoleInteractionService.Console;
        set => _consoleInteractionService.Console = value;
    }
 
    // The extension's local stdout/stderr stream is mirrored into VS Code's debug console,
    // which displays OSC-8 terminal hyperlinks as raw text instead of rendering them.
    public bool SupportsLinks => false;
 
    public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayPlainTextAsync(text, _cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplayRawText(text, consoleOverride);
    }
 
    public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null, int? maxWidth = null)
    {
        // Send raw markdown to extension (it can handle markdown natively)
        // Convert to Spectre markup for console display
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.LogMessageAsync(LogLevel.Information, markdown, _cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplayMarkdown(markdown, consoleOverride, maxWidth);
    }
 
    public void DisplayMarkupLine(string markup)
    {
        // Strip markup for backchannel, display as-is to console
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.LogMessageAsync(LogLevel.Information, StringUtils.RemoveMarkup(markup), _cancellationToken));
        Debug.Assert(result);
        _consoleInteractionService.DisplayMarkupLine(markup);
    }
 
    public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null)
    {
        _consoleInteractionService.DisplayVersionUpdateNotification(newerVersion, updateCommand);
    }
 
    public void DisplayRenderable(IRenderable renderable)
    {
        _consoleInteractionService.DisplayRenderable(renderable);
    }
 
    public Task DisplayLiveAsync(IRenderable initialRenderable, Func<Action<IRenderable>, Task> callback)
    {
        return _consoleInteractionService.DisplayLiveAsync(initialRenderable, callback);
    }
 
    public void LogMessage(LogLevel logLevel, string message)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.LogMessageAsync(logLevel, StringUtils.RemoveMarkup(message), _cancellationToken));
        Debug.Assert(result);
    }
 
    public Task LaunchAppHostAsync(string projectFile, List<string> arguments, List<EnvVar> environment, bool debug)
    {
        return Backchannel.LaunchAppHostAsync(projectFile, arguments, environment, debug, _cancellationToken);
    }
 
    public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false)
    {
        _consoleInteractionService.WriteConsoleLog(message, lineNumber, type, isErrorMessage);
    }
 
    public void NotifyAppHostStartupCompleted()
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.NotifyAppHostStartupCompletedAsync(_cancellationToken));
        Debug.Assert(result);
    }
 
    public void DisplayConsolePlainText(string message)
    {
        _consoleInteractionService.DisplayPlainText(message);
    }
 
    public Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, DebugSessionOptions? options = null)
    {
        return Backchannel.StartDebugSessionAsync(workingDirectory, projectFile, debug, options, _cancellationToken);
    }
 
    public void WriteDebugSessionMessage(string message, bool stdout, string? textStyle)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.WriteDebugSessionMessageAsync(StringUtils.RemoveMarkup(message), stdout, textStyle, _cancellationToken));
        Debug.Assert(result);
    }
 
    public void WriteAppHostLogEntry(ExtensionAppHostLogEntry entry)
    {
        var result = _extensionTaskChannel.Writer.TryWrite(async () => await Backchannel.WriteAppHostLogEntryAsync(entry, _cancellationToken).ConfigureAwait(false));
        Debug.Assert(result);
    }
 
    private async Task ProcessExtensionTaskChannelAsync()
    {
        try
        {
            while (await _extensionTaskChannel.Reader.WaitToReadAsync(_cancellationToken).ConfigureAwait(false))
            {
                try
                {
                    var taskFunction = await _extensionTaskChannel.Reader.ReadAsync().ConfigureAwait(false);
                    await taskFunction.Invoke();
                }
                catch (Exception ex) when (ex is not ExtensionOperationCanceledException)
                {
                    try
                    {
                        await Backchannel.DisplayErrorAsync(StringUtils.RemoveMarkup(ex.Message), _cancellationToken);
                    }
                    catch (Exception displayErrorException)
                    {
                        // Keep the single-reader pump alive even when the extension connection is
                        // already broken; otherwise the final flush sentinel can never be processed.
                        _logger.LogDebug(displayErrorException, "Failed to display an extension operation error through the extension backchannel.");
                    }
 
                    _consoleInteractionService.DisplayError(ex.Message);
                }
            }
        }
        catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested)
        {
            // Expected during disposal — the channel was completed and/or the token cancelled.
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error in extension task channel processing loop.");
        }
    }
 
    public void Dispose()
    {
        _extensionTaskChannel.Writer.TryComplete();
        _cts.Cancel();
        _cts.Dispose();
    }
}