From 28581124f93a8cabc3934174bd3a13bf2a7800a2 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 2 Sep 2026 09:07:01 -0700 Subject: [PATCH 1/6] Agent Host: surface remote connection state in sessions A session backed by an unreachable remote host previously spun forever with no explanation and no way to recover. Surface that state and make it actionable: - Derive a session-scoped `remoteConnectionStatus` from the provider so the chat surface can react to connection state, not just host-scoped UI. - Add machine-readable transport failure reasons so a stopped host is distinguishable from an unreachable one. - Show a centered recovery state with a Start action when a session has no visible transcript, and a quiet inline banner when a rendered transcript drops mid-use. - Report live bootstrap progress ("Downloading server (24%)") while a connect is in flight, via a shared progress parser. - Split WSL startup, idle, and ceiling timeouts so a cold VM boot is not mistaken for a hung connection. - Gate terminal launches on host availability and re-resolve chat content when a provider registers late. Collect the connection concerns in ChatGroupView behind a single SessionRemoteConnection, expressing state as observables with one derived resolving which surface is visible. Read-only remains a peer of connection state rather than part of it, since a read-only chat can also be reconnecting. The quiet-reconnect delay is now a deadline, so re-arming is idempotent instead of relying on a guard field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/learnings/sessions.md | 14 + .../browser/agentHostProtocolClient.ts | 16 +- .../browser/remoteAgentHostServiceImpl.ts | 46 ++- .../remoteAgentHostBootstrapProgress.ts | 93 +++++ .../common/remoteAgentHostService.ts | 20 +- .../common/state/protocol/version/registry.ts | 1 + .../common/state/sessionTransport.ts | 12 +- .../wslRemoteAgentHostServiceImpl.ts | 6 +- .../node/sshRemoteAgentHostHelpers.ts | 6 +- .../node/wslRemoteAgentHostService.ts | 91 ++++- .../remoteAgentHostBootstrapProgress.test.ts | 92 +++++ .../agentHostProtocolClient.test.ts | 22 +- .../remoteAgentHostService.test.ts | 90 +++- .../node/wslRemoteAgentHostService.test.ts | 62 ++- src/vs/sessions/SESSIONS.md | 2 + .../sessions/browser/parts/chatGroupView.ts | 119 ++++-- src/vs/sessions/browser/parts/chatView.ts | 7 + .../media/remoteHostUnavailableEmptyState.css | 62 +++ .../parts/remoteHostUnavailableEmptyState.ts | 96 +++++ .../browser/parts/sessionReadOnlyBanner.ts | 26 +- .../browser/parts/sessionRemoteConnection.ts | 256 ++++++++++++ .../sessions/contrib/chat/browser/chatView.ts | 31 +- .../chat/test/browser/chatView.test.ts | 23 +- .../browser/baseAgentHostSessionsProvider.ts | 78 +++- .../localAgentHostSessionsProvider.test.ts | 7 + .../entryDrivenProviderContribution.ts | 16 +- .../browser/remoteAgentHost.contribution.ts | 4 +- .../remoteAgentHostSessionsProvider.ts | 14 +- .../browser/sshAgentHost.contribution.ts | 4 - .../webSocketAgentHost.contribution.ts | 4 - .../remoteAgentHostSessionsProvider.test.ts | 78 +++- .../browser/wslAgentHost.contribution.test.ts | 95 +++++ .../browser/agentHostSessionTaskRunner.ts | 13 +- .../browser/sessionsTerminalContribution.ts | 31 +- .../agentHostSessionTaskRunner.test.ts | 25 +- .../sessionsTerminalContribution.test.ts | 82 +++- .../sessions/browser/visibleSessions.ts | 2 + .../services/sessions/common/session.ts | 18 + .../test/browser/visibleSessions.test.ts | 9 +- .../test/browser/chatGroupsView.test.ts | 385 +++++++++++++++++- ...remoteHostUnavailableEmptyState.fixture.ts | 46 +++ .../browser/sessionReadOnlyBanner.fixture.ts | 26 ++ 42 files changed, 1944 insertions(+), 186 deletions(-) create mode 100644 src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts create mode 100644 src/vs/platform/agentHost/test/common/remoteAgentHostBootstrapProgress.test.ts create mode 100644 src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css create mode 100644 src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts create mode 100644 src/vs/sessions/browser/parts/sessionRemoteConnection.ts create mode 100644 src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts diff --git a/.github/learnings/sessions.md b/.github/learnings/sessions.md index a9927863c25181..de95b07be7b0cc 100644 --- a/.github/learnings/sessions.md +++ b/.github/learnings/sessions.md @@ -39,6 +39,20 @@ Scope: `src/vs/sessions/**` - **Evidence:** Historical issues included actions targeting the window-global session, stale repository context on recycled rows, and late async results publishing into a replacement view. - **Disposition:** Candidate for focused UI specifications and lifecycle tests. +## Attribute a console error to the provider that backs the surface + +- **Scope:** `src/vs/sessions/contrib/providers/**` +- **Learning:** The sessions list resolves every registered provider, so an error in the console may come from a provider unrelated to the surface the user was operating. Identify which provider backs that surface before editing; a stack trace shows where an error was thrown, not which feature produced it. Several providers share a product name across the workbench and the Copilot extension without sharing an implementation. +- **Evidence:** A cloud sandbox report was traced into the Copilot extension's similarly named cloud provider, which shares neither code nor identifier with the Agents Window sandbox provider. +- **Disposition:** Candidate for the Sessions skill if provider-attribution mistakes recur. + +## Rule out local product configuration before suspecting product code + +- **Scope:** `src/vs/sessions/**`, `src/vs/platform/agentHost/**` +- **Learning:** Features reading `product.defaultChatAgent` can misbehave in dev builds because `product.overrides.json` is gitignored and drifts from the shipped configuration without appearing in any diff. Compare the specific keys a feature reads against the distro mixin before treating the behavior as a code defect, and note that ordered configuration arrays carry meaning by index. +- **Evidence:** A rotated `providerScopes` array left index 0 — the permissive scope set that the cloud sandbox client reads directly — without repository access, presenting as an authentication bug in feature code. +- **Disposition:** Candidate for repository onboarding or debugging guidance if dev-only configuration drift recurs. + ## Capture live editor state independently of persistence - **Scope:** `src/vs/sessions/contrib/layout/**` diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 3e056b1719a950..3113d9a5f80dcc 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -31,7 +31,7 @@ import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCom import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; -import { isClientTransport, NonReconnectableTransportError, type IProtocolTransport } from '../common/state/sessionTransport.js'; +import { isClientTransport, NonReconnectableTransportError, type AgentHostTransportFailureReason, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/errors.js'; import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -248,7 +248,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _onDidReceiveOtlpLogs = this._register(new Emitter()); readonly onDidReceiveOtlpLogs = this._onDidReceiveOtlpLogs.event; - private readonly _onDidClose = this._register(new Emitter()); + private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; private readonly _onDidFatalClose = this._register(new Emitter()); @@ -555,7 +555,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect throw error; } if (error instanceof NonReconnectableTransportError) { - this._handleFatalClose(protocolError); + this._handleFatalClose(protocolError, error.reason); throw error; } if (this._state.kind === AgentHostClientState.Reconnecting) { @@ -779,7 +779,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } if (err instanceof NonReconnectableTransportError) { const protocolError = new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, err.message); - this._handleFatalClose(protocolError); + this._handleFatalClose(protocolError, err.reason); return; } if (err instanceof InitialAuthenticationError) { @@ -1684,12 +1684,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } - private _handleFatalClose(error: ProtocolError): void { + private _handleFatalClose(error: ProtocolError, reason?: AgentHostTransportFailureReason): void { this._onDidFatalClose.fire(error); - this._handleClose(error); + this._handleClose(error, reason); } - private _handleClose(error: ProtocolError): void { + private _handleClose(error: ProtocolError, reason?: AgentHostTransportFailureReason): void { if (this._state.kind === AgentHostClientState.Closed) { return; } @@ -1715,7 +1715,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._implicitReadGrants.clear(); this._resourceService.connectionClosed(this._resourceIdentity); this._transitionTo({ kind: AgentHostClientState.Closed, error }); - this._onDidClose.fire(); + this._onDidClose.fire(reason); } private async _raceClose(promise: Promise): Promise { diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index f2a76d4dd681b5..8eee6ef1f72dfd 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -40,7 +40,7 @@ import { RemoteAgentHostEntryType, } from '../common/remoteAgentHostService.js'; import { computeReconnectDelay, hasExhaustedReconnectAttempts } from '../common/reconnectPolicy.js'; -import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import { AgentHostProtocolClient, InitialAuthenticationError } from './agentHostProtocolClient.js'; import { WebSocketClientTransport } from './webSocketClientTransport.js'; import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; @@ -51,7 +51,7 @@ import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from /** Tracks a single remote connection through its lifecycle. */ interface IConnectionEntry { readonly store: DisposableStore; - readonly client: IRemoteAgentHostProtocolClient; + client?: IRemoteAgentHostProtocolClient; /** * Optional teardown for the shared-process tunnel that this entry's * transport is using (SSH or dev-tunnels). Tracked separately from @@ -234,8 +234,8 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo result.push({ address, name: this._names.get(address) ?? address, - clientId: entry.client.clientId, - defaultDirectory: entry.client.defaultDirectory, + clientId: entry.client?.clientId, + defaultDirectory: entry.client?.defaultDirectory, status: entry.status, }); } @@ -285,9 +285,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo async triggerServerUpgrade(address: string, method: string): Promise { const normalized = normalizeRemoteAgentHostAddress(address); - const entry = this._entries.get(normalized); - if (!entry) { - throw new Error(`No remote agent host entry found for ${address}.`); + const client = this._entries.get(normalized)?.client; + if (!client) { + throw new Error(`No usable remote agent host client found for ${address}.`); } // The protocol client may be in any state: it might have completed // the handshake (Connected) or it might be sitting on an @@ -296,7 +296,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // method name the host advertised in its `_meta` payload; the // server handler allows it pre-`initialize`. const result = await raceTimeout( - entry.client.triggerVscodeUpgrade(method), + client.triggerVscodeUpgrade(method), RemoteAgentHostService.UpgradeRequestTimeout, ); if (result === undefined) { @@ -438,11 +438,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo notifyConnectionClosed(address: string): void { const normalized = normalizeRemoteAgentHostAddress(address); const entry = this._entries.get(normalized); - if (entry) { + if (entry?.client) { this._logService.info(`[RemoteAgentHost] notifyConnectionClosed: notifying protocol client for ${normalized}`); entry.client.notifyTransportClosed(); } else { - this._logService.info(`[RemoteAgentHost] notifyConnectionClosed: no entry found for ${normalized} (already removed?)`); + this._logService.info(`[RemoteAgentHost] notifyConnectionClosed: no active client found for ${normalized}`); } } @@ -576,7 +576,20 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo createdConnection = await factory.createConnection(entryToCreate, options); } catch (err) { this._logService.error(`[RemoteAgentHost] Failed to create a connection to ${address}. Verify address and connectionToken`, err); + // A factory can fail before any client exists — a stopped WSL distro is + // rejected by its precondition check, never reaching the handshake below. + // Retain the entry so its status remains visible to consumers. + const disconnectReason = err instanceof NonReconnectableTransportError ? err.reason : AgentHostTransportFailureReason.Unknown; + this._entries.set(address, { + store: new DisposableStore(), + connected: false, + status: RemoteAgentHostConnectionStatus.disconnectedBecause(disconnectReason), + reconnectTransfersTransportOwnership: false, + }); this._rejectPendingConnectionWait(address, err); + // Nothing else reports this failure: no entry was created, so consumers + // only learn the address became unavailable from this notification. + this._onDidChangeConnections.fire(); if (!isTerminalConnectError(err) && !this._store.isDisposed && this._remoteAgentHostsEnabled.get()) { this._scheduleReconnect(address, entryToCreate.connectionToken); } @@ -611,13 +624,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // current entry for this address is still the one we created. const isCurrentEntry = () => this._entries.get(address) === entry; - store.add(client.onDidClose(() => { + store.add(client.onDidClose(reason => { if (!isCurrentEntry()) { return; } this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.disconnected; + entry.status = RemoteAgentHostConnectionStatus.disconnectedBecause(reason ?? AgentHostTransportFailureReason.Unknown); + entry.client = undefined; + disposeEntry(entry); this._onDidChangeConnections.fire(); // Schedule reconnect if the address is still configured. This is // the "fatal" path — the protocol client already gave up its own @@ -715,9 +730,10 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return; } - entry.status = RemoteAgentHostConnectionStatus.disconnected; - // Clean up the failed entry - this._entries.delete(address); + const disconnectReason = err instanceof NonReconnectableTransportError ? err.reason : AgentHostTransportFailureReason.Unknown; + entry.status = RemoteAgentHostConnectionStatus.disconnectedBecause(disconnectReason); + entry.client = undefined; + // Clean up the failed client while retaining its entry and status. disposeEntry(entry); this._rejectPendingConnectionWait(address, err); this._onDidChangeConnections.fire(); diff --git a/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts new file mode 100644 index 00000000000000..ca648778dacd18 --- /dev/null +++ b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable, observableValue } from '../../../base/common/observable.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; + +const SERVER_DOWNLOAD_PROGRESS_REGEX = /^Downloading server:\s*\d+\/\d+\s+\((?\d{1,3})%\)/; + +export interface IRemoteAgentHostBootstrapProgress { + readonly phase: 'serverDownload'; + readonly percentage: number; +} + +/** Redact connection tokens from output produced by a remote agent host. */ +export function redactToken(text: string): string { + return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); +} + +/** Parses and throttles recognized bootstrap progress after redacting its input. */ +export class RemoteAgentHostBootstrapProgressReporter extends Disposable { + + private readonly _progress = observableValue(this, undefined); + readonly progress: IObservable = this._progress; + + private _lastReportTime: number | undefined; + private _pendingProgress: IRemoteAgentHostBootstrapProgress | undefined; + private _timeoutHandle: ReturnType | undefined; + + constructor(private readonly _intervalMs = 250) { + super(); + } + + /** Consumes one decoded output line. */ + acceptLine(line: string): void { + const progress = this._parseProgress(redactToken(line)); + if (!progress) { + return; + } + + const now = Date.now(); + if (this._lastReportTime === undefined || now - this._lastReportTime >= this._intervalMs) { + this._lastReportTime = now; + this._progress.set(progress, undefined); + return; + } + + this._pendingProgress = progress; + if (this._timeoutHandle === undefined) { + this._timeoutHandle = setTimeout(() => { + this._timeoutHandle = undefined; + const pendingProgress = this._pendingProgress; + if (pendingProgress) { + this._lastReportTime = Date.now(); + this._progress.set(pendingProgress, undefined); + this._pendingProgress = undefined; + } + }, this._intervalMs - (now - this._lastReportTime)); + } + } + + /** Immediately publishes the newest pending progress, if any. */ + flush(): void { + if (this._timeoutHandle !== undefined) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; + } + const pendingProgress = this._pendingProgress; + if (pendingProgress) { + this._lastReportTime = Date.now(); + this._progress.set(pendingProgress, undefined); + this._pendingProgress = undefined; + } + } + + override dispose(): void { + if (this._timeoutHandle !== undefined) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; + } + super.dispose(); + } + + private _parseProgress(line: string): IRemoteAgentHostBootstrapProgress | undefined { + const match = SERVER_DOWNLOAD_PROGRESS_REGEX.exec(line); + const percentage = Number(match?.groups?.percentage); + if (!Number.isInteger(percentage) || percentage < 0 || percentage > 100) { + return undefined; + } + return { phase: 'serverDownload', percentage }; + } +} diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 5a03828ea833b6..fa0f7d19b71588 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -13,6 +13,7 @@ import { StorageScope, StorageTarget, type IStorageService } from '../../storage import type { IAgentConnection } from './agentService.js'; import type { UnsupportedProtocolVersionErrorData } from './state/protocol/errors.js'; import { AHP_UNSUPPORTED_PROTOCOL_VERSION, ProtocolError } from './state/sessionProtocol.js'; +import { AgentHostTransportFailureReason } from './state/sessionTransport.js'; import { readUnsupportedProtocolVersionErrorMeta, type IVscodeUpgradeResult } from './state/protocolUpgrade.js'; import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js'; import { DEFAULT_RECONNECT_POLICY, type IRemoteAgentHostReconnectPolicy } from './reconnectPolicy.js'; @@ -36,7 +37,7 @@ export type RemoteAgentHostConnectionStatus = * `disconnected` (no connection, nothing in flight). */ | { readonly kind: 'reconnecting' } - | { readonly kind: 'disconnected' } + | { readonly kind: 'disconnected'; readonly reason: AgentHostTransportFailureReason } | { readonly kind: 'incompatible'; /** Human-readable reason from the host (or a synthesised one when the host did not send one). */ @@ -62,7 +63,13 @@ export namespace RemoteAgentHostConnectionStatus { /** Singleton "reconnecting" status. */ export const reconnecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'reconnecting' }); /** Singleton "disconnected" status. */ - export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected' }); + export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected', reason: AgentHostTransportFailureReason.Unknown }); + /** Build a disconnected status with a machine-readable reason. */ + export function disconnectedBecause(reason: AgentHostTransportFailureReason): RemoteAgentHostConnectionStatus { + return reason === AgentHostTransportFailureReason.Unknown + ? disconnected + : Object.freeze({ kind: 'disconnected', reason }); + } /** Build an "incompatible" status from a host-supplied message and the versions involved. */ export function incompatible(message: string, supportedByClient: readonly string[], offeredByServer?: readonly string[], vscodeUpgradeMethod?: string): RemoteAgentHostConnectionStatus { return Object.freeze({ kind: 'incompatible', message, supportedByClient, offeredByServer, vscodeUpgradeMethod }); @@ -243,7 +250,7 @@ export type RemoteAgentHostProtocolClientState = 'connecting' | 'incompatible' | */ export interface IRemoteAgentHostProtocolClient extends IAgentConnection, IDisposable { readonly defaultDirectory: string | undefined; - readonly onDidClose: Event; + readonly onDidClose: Event; readonly onDidChangeConnectionState: Event; connect(): Promise; notifyTransportClosed(): void; @@ -662,6 +669,10 @@ export interface IRemoteAgentHostService { /** Currently connected remote addresses with metadata. */ readonly connections: readonly IRemoteAgentHostConnectionInfo[]; + /** + * Returns the last machine-readable disconnect reason for an address whose + * failed connection is no longer represented in {@link connections}. + */ /** All remote agent host entries exposed by registered factories, regardless of connection status. */ readonly configuredEntries: readonly IRemoteAgentHostEntry[]; @@ -743,7 +754,8 @@ export interface IRemoteAgentHostService { export interface IRemoteAgentHostConnectionInfo { readonly address: string; readonly name: string; - readonly clientId: string; + /** Identifier of the backing protocol client, when one exists. */ + readonly clientId?: string; readonly defaultDirectory?: string; readonly status: RemoteAgentHostConnectionStatus; } diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 86a7dfeb64226a..9f8ba7ec561f1f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '1.0.0', '0.9.0', '0.8.0', '0.7.0', diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts index fa1bcccbf578bb..454f194f4179ed 100644 --- a/src/vs/platform/agentHost/common/state/sessionTransport.ts +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -15,8 +15,18 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import type { AgentHostClientConnectionKind, AgentHostTransportKind } from '../agentHostTelemetry.js'; import type { ProtocolMessage, AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcResponse, JsonRpcRequest } from './sessionProtocol.js'; +/** Machine-readable reasons a transport cannot be reconnected. */ +export const enum AgentHostTransportFailureReason { + Unknown = 'unknown', + HostNotRunning = 'hostNotRunning', +} + /** Signals that reconnecting the transport cannot recover the connection. */ -export class NonReconnectableTransportError extends Error { } +export class NonReconnectableTransportError extends Error { + constructor(message: string, readonly reason: AgentHostTransportFailureReason = AgentHostTransportFailureReason.Unknown) { + super(message); + } +} /** * A bidirectional transport for protocol messages. Implementations handle diff --git a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts index 45a48caf665f31..7162484488d9cb 100644 --- a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts @@ -19,7 +19,7 @@ import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../common/agentService.js'; import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { ReconnectingRelayTransport } from '../common/relayTransport.js'; -import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import { AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; import { @@ -70,7 +70,7 @@ export class WSLRelayClientFactory implements IWSLRelayClientFactory { try { const runningDistros = await mainService.listRunningDistros().catch((): string[] => []); if (!runningDistros.includes(config.distro)) { - throw new NonReconnectableTransportError(`WSL distro '${config.distro}' is not running.`); + throw new NonReconnectableTransportError(`WSL distro '${config.distro}' is not running.`, AgentHostTransportFailureReason.HostNotRunning); } const result = await mainService.reconnect(config.distro, config.name, config.remoteAgentHostCommand, false); return { @@ -263,7 +263,7 @@ class WSLConnectionFactory extends Disposable implements IRemoteAgentHostConnect private async _ensureDistroIsRunning(distro: string): Promise { const runningDistros = await this._mainService.listRunningDistros(); if (!runningDistros.includes(distro)) { - throw new NonReconnectableTransportError(`WSL distro '${distro}' is not running.`); + throw new NonReconnectableTransportError(`WSL distro '${distro}' is not running.`, AgentHostTransportFailureReason.HostNotRunning); } } diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts index 25a5e7d5334c28..50a7f4088c468e 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts @@ -9,6 +9,7 @@ import { CancellationError } from '../../../base/common/errors.js'; import { vArray, vObj, vString, vUnknown } from '../../../base/common/validation.js'; import { TelemetryConfiguration } from '../../telemetry/common/telemetry.js'; import { getAgentHostEndpointIdentityKey, IAgentHostEndpointMetadata, parseAgentHostEndpointRegistry } from '../common/agentHostEndpointRegistry.js'; +export { redactToken } from '../common/remoteAgentHostBootstrapProgress.js'; /** * Validate that a quality string is safe for bare interpolation in shell commands. @@ -280,11 +281,6 @@ export function isValidFallbackCLIPath(candidate: string, serverDataFolderName: return false; } -/** Redact connection tokens from log output. */ -export function redactToken(text: string): string { - return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); -} - /** * Match the `ws://127.0.0.1:PORT[?tkn=TOKEN]` URL emitted by `code agent host` * on stdout/stderr. Shared by SSH and WSL agent-host transports — both spawn diff --git a/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts index fd43925e4cf137..1745e65b921a41 100644 --- a/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts @@ -6,7 +6,8 @@ import type WebSocket from 'ws'; import * as cp from 'child_process'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { autorun } from '../../../base/common/observable.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { removeAnsiEscapeCodes } from '../../../base/common/strings.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; @@ -14,6 +15,7 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; +import { redactToken, RemoteAgentHostBootstrapProgressReporter } from '../common/remoteAgentHostBootstrapProgress.js'; import type { IRelayMessage } from '../common/relayTransport.js'; import { IWSLRemoteAgentHostMainService, @@ -22,7 +24,7 @@ import { type IWSLConnectResult, type IWSLDistro, } from '../common/wslRemoteAgentHost.js'; -import { redactToken, resolveRemotePlatform } from './sshRemoteAgentHostHelpers.js'; +import { resolveRemotePlatform } from './sshRemoteAgentHostHelpers.js'; import { composeAgentHostBootstrapScript, decodeWslOutput, @@ -37,7 +39,14 @@ import { const LOG_PREFIX = '[WSLRemoteAgentHost]'; -/** Max time `code agent host` may be silent before printing its `ws://` URL. */ +/** + * Max time a stopped WSL distro may take to boot and produce the bootstrap's + * first output. This intentionally includes VM startup and login-shell profile + * sourcing, which can legitimately exceed the post-output idle budget. + */ +const AGENT_HOST_INITIAL_OUTPUT_TIMEOUT_MS = 3 * 60_000; + +/** Max time `code agent host` may be silent after bootstrap output has started. */ const AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS = 60_000; /** Absolute upper bound for bootstrap, including CLI and server downloads. */ @@ -251,9 +260,28 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem } }; + const bootstrapProgressDisposables = new DisposableStore(); + const bootstrapProgressReporter = bootstrapProgressDisposables.add(new RemoteAgentHostBootstrapProgressReporter()); + bootstrapProgressDisposables.add(autorun(reader => { + const progress = bootstrapProgressReporter.progress.read(reader); + if (progress?.phase === 'serverDownload') { + reportProgress(localize('wslProgressDownloadingServer', "Downloading server ({0}%)", progress.percentage)); + } + })); + const flushBootstrapProgress = () => { + bootstrapProgressReporter.flush(); + }; + + let initialOutputTimeoutHandle: ReturnType | undefined; let outputIdleTimeoutHandle: ReturnType | undefined; let overallTimeoutHandle: ReturnType | undefined; + let hasProducedOutput = false; + let readySettled = false; const clearReadyTimeouts = () => { + if (initialOutputTimeoutHandle !== undefined) { + clearTimeout(initialOutputTimeoutHandle); + initialOutputTimeoutHandle = undefined; + } if (outputIdleTimeoutHandle !== undefined) { clearTimeout(outputIdleTimeoutHandle); outputIdleTimeoutHandle = undefined; @@ -263,19 +291,27 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem overallTimeoutHandle = undefined; } }; - const rejectForTimeout = (message: string) => { + const rejectReady = (error: Error) => { + if (readySettled) { + return; + } + readySettled = true; clearReadyTimeouts(); - urlReject?.(new Error(`${LOG_PREFIX} ${message}\nOutput: ${outputLines.join('\n')}`)); + flushBootstrapProgress(); + urlReject?.(error); + }; + const rejectForTimeout = (message: string) => { + rejectReady(new Error(`${LOG_PREFIX} ${message}\nOutput: ${outputLines.join('\n')}`)); }; const armOutputIdleTimeout = () => { - if (url) { + if (readySettled) { return; } if (outputIdleTimeoutHandle !== undefined) { clearTimeout(outputIdleTimeoutHandle); } outputIdleTimeoutHandle = setTimeout(() => { - rejectForTimeout(`Timed out waiting for agent host in '${distro}' to print its WebSocket URL: no output for ${AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS}ms.`); + rejectForTimeout(`Timed out waiting for agent host in '${distro}' to print its WebSocket URL: exceeded the ${AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS}ms output-idle budget after output started.`); }, AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS); }; @@ -287,17 +323,32 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem // etc. — arrive on stderr without `WSL_UTF8=1`). const cleanText = removeAnsiEscapeCodes(decodeWslOutput(data)); for (const rawLine of cleanText.split(/\r\n|\r|\n/)) { + if (readySettled) { + return; + } const line = rawLine.trimEnd(); if (!line) { continue; } + if (!hasProducedOutput) { + hasProducedOutput = true; + if (initialOutputTimeoutHandle !== undefined) { + clearTimeout(initialOutputTimeoutHandle); + initialOutputTimeoutHandle = undefined; + } + } armOutputIdleTimeout(); - appendLine(line); - this._logService.trace(`${LOG_PREFIX} [${distro}] ${redactToken(line)}`); + const redactedLine = redactToken(line); + appendLine(redactedLine); + this._logService.trace(`${LOG_PREFIX} [${distro}] ${redactedLine}`); + bootstrapProgressReporter.acceptLine(line); if (!url) { const match = extractAgentHostWebSocketURL(line); if (match) { + flushBootstrapProgress(); url = match.url; + readySettled = true; + clearReadyTimeouts(); urlResolve?.({ url: match.url, token: match.token }); } } @@ -307,25 +358,28 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem child.stdout?.on('data', onStreamData); child.stderr?.on('data', onStreamData); - // Race the URL parse against the child dying, output going idle, and - // an overall ceiling. Bootstrap downloads regularly report progress, - // so only a period of silence indicates that it has become stuck. + // Race the URL parse against the child dying, initial startup silence, + // post-output silence, and an overall ceiling. Bootstrap downloads + // regularly report progress, so once output starts only silence indicates + // that it has become stuck. // `outputLines` is already redacted in `appendLine` — no extra wrap needed. - armOutputIdleTimeout(); + if (!hasProducedOutput) { + initialOutputTimeoutHandle = setTimeout(() => { + rejectForTimeout(`Timed out waiting for agent host in '${distro}' to produce initial output: exceeded the ${AGENT_HOST_INITIAL_OUTPUT_TIMEOUT_MS}ms startup budget.`); + }, AGENT_HOST_INITIAL_OUTPUT_TIMEOUT_MS); + } overallTimeoutHandle = setTimeout(() => { rejectForTimeout(`Timed out waiting for agent host in '${distro}' to print its WebSocket URL: exceeded the overall ${AGENT_HOST_READY_OVERALL_TIMEOUT_MS}ms bootstrap ceiling.`); }, AGENT_HOST_READY_OVERALL_TIMEOUT_MS); child.once('exit', (code, signal) => { if (!url) { - clearReadyTimeouts(); - urlReject?.(new Error(`${LOG_PREFIX} Agent host in '${distro}' exited (code=${code}, signal=${signal}) before printing its WebSocket URL.\nOutput: ${outputLines.join('\n')}`)); + rejectReady(new Error(`${LOG_PREFIX} Agent host in '${distro}' exited (code=${code}, signal=${signal}) before printing its WebSocket URL.\nOutput: ${outputLines.join('\n')}`)); } }); child.once('error', err => { if (!url) { - clearReadyTimeouts(); - urlReject?.(new Error(`${LOG_PREFIX} Failed to start agent host in '${distro}': ${err.message}\nOutput: ${outputLines.join('\n')}`)); + rejectReady(new Error(`${LOG_PREFIX} Failed to start agent host in '${distro}': ${err.message}\nOutput: ${outputLines.join('\n')}`)); } }); @@ -334,9 +388,12 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem resolvedUrl = await urlPromise; } catch (err) { clearReadyTimeouts(); + flushBootstrapProgress(); + bootstrapProgressDisposables.dispose(); this._killChild(child); throw err; } + bootstrapProgressDisposables.dispose(); clearReadyTimeouts(); reportProgress(localize('wslProgressConnecting', "Connecting to agent host in {0}...", distro)); diff --git a/src/vs/platform/agentHost/test/common/remoteAgentHostBootstrapProgress.test.ts b/src/vs/platform/agentHost/test/common/remoteAgentHostBootstrapProgress.test.ts new file mode 100644 index 00000000000000..9e8d6e0de6400f --- /dev/null +++ b/src/vs/platform/agentHost/test/common/remoteAgentHostBootstrapProgress.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { redactToken, RemoteAgentHostBootstrapProgressReporter, type IRemoteAgentHostBootstrapProgress } from '../../common/remoteAgentHostBootstrapProgress.js'; + +suite('RemoteAgentHostBootstrapProgressReporter', () => { + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses a server download progress line', () => { + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + reporter.acceptLine('Downloading server: 182761536/228859480 (80%)'); + + assert.deepStrictEqual(reporter.progress.get(), { phase: 'serverDownload', percentage: 80 }); + }); + + test('ignores unrecognized output', () => { + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + reporter.acceptLine('bootstrap shell noise'); + + assert.strictEqual(reporter.progress.get(), undefined); + }); + + test('redacts token-bearing output before producing progress', () => { + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + reporter.acceptLine('Downloading server: 80/100 (80%)?tkn=bootstrap-token'); + + assert.deepStrictEqual({ + redacted: redactToken('Downloading server: 80/100 (80%)?tkn=bootstrap-token'), + progress: reporter.progress.get(), + }, { + redacted: 'Downloading server: 80/100 (80%)?tkn=***', + progress: { phase: 'serverDownload', percentage: 80 }, + }); + }); + + test('collapses a burst while preserving its final progress', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const progress: IRemoteAgentHostBootstrapProgress[] = []; + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + disposables.add(autorun(reader => { + const update = reporter.progress.read(reader); + if (update) { + progress.push(update); + } + })); + + for (const percentage of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) { + reporter.acceptLine(`Downloading server: ${percentage}/100 (${percentage}%)`); + } + await timeout(250); + + assert.deepStrictEqual(progress, [ + { phase: 'serverDownload', percentage: 10 }, + { phase: 'serverDownload', percentage: 100 }, + ]); + }); + }); + + test('retains progress across interleaved output noise', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const progress: IRemoteAgentHostBootstrapProgress[] = []; + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + disposables.add(autorun(reader => { + const update = reporter.progress.read(reader); + if (update) { + progress.push(update); + } + })); + + reporter.acceptLine('Downloading server: 10/100 (10%)'); + reporter.acceptLine('bootstrap shell noise'); + reporter.acceptLine('Downloading server: 20/100 (20%)'); + await timeout(250); + + assert.deepStrictEqual(progress, [ + { phase: 'serverDownload', percentage: 10 }, + { phase: 'serverDownload', percentage: 20 }, + ]); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 5533239c9b2bc3..240ff3b9e60fc6 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -31,7 +31,7 @@ import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, ty import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { AUTOMATION_CATALOG_URI, buildChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; -import { NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -2340,6 +2340,7 @@ suite('AgentHostProtocolClient', () => { test('does not retry a non-reconnectable initial transport failure', async () => { const { client, transports } = createFactoryClient(); const fatalErrors: string[] = []; + const closeReason = Event.toPromise(client.onDidClose); disposables.add(client.onDidFatalClose(error => fatalErrors.push(error.message))); const connectPromise = client.connect(); transports[0].connectDeferred.error(new NonReconnectableTransportError('terminal failure')); @@ -2350,10 +2351,29 @@ suite('AgentHostProtocolClient', () => { state: client.connectionState, transportCount: transports.length, fatalErrors, + closeReason: await closeReason, }, { state: AgentHostClientState.Closed, transportCount: 1, fatalErrors: ['terminal failure'], + closeReason: AgentHostTransportFailureReason.Unknown, + }); + }); + + test('reports a host-not-running terminal transport failure when it closes', async () => { + const { client, transports } = createFactoryClient(); + const closeReason = Event.toPromise(client.onDidClose); + const connectPromise = client.connect(); + transports[0].connectDeferred.error(new NonReconnectableTransportError('WSL distro is not running.', AgentHostTransportFailureReason.HostNotRunning)); + + await assert.rejects(connectPromise, /not running/); + + assert.deepStrictEqual({ + state: client.connectionState, + closeReason: await closeReason, + }, { + state: AgentHostClientState.Closed, + closeReason: AgentHostTransportFailureReason.HostNotRunning, }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 5d87f7057654e2..6f639b1eb3c8e0 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -26,6 +26,7 @@ import type { Implementation } from '../../common/state/protocol/common/commands import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { computeReconnectDelay } from '../../common/reconnectPolicy.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError } from '../../common/state/sessionTransport.js'; interface IRemoteAgentHostServiceTestAccess { readonly _reconnectAttempts: Map; @@ -51,7 +52,7 @@ class MockProtocolClient extends Disposable { private static _nextId = 1; readonly clientId = `mock-client-${MockProtocolClient._nextId++}`; - private readonly _onDidClose = this._register(new Emitter()); + private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; readonly onDidAction = Event.None; readonly onDidNotification = Event.None; @@ -78,8 +79,8 @@ class MockProtocolClient extends Disposable { return { ok: true, upgradeStarted: true }; } - fireClose(): void { - this._onDidClose.fire(); + fireClose(reason?: AgentHostTransportFailureReason): void { + this._onDidClose.fire(reason); } fireConnectionState(state: 'connecting' | 'reconnecting' | 'connected' | 'incompatible' | 'closed'): void { @@ -92,6 +93,7 @@ class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnec private readonly _entries = observableValue(this, []); private readonly _createdConnections = new Map(); + private readonly _failures = new Map(); private readonly _onDidCreateConnection = this._register(new Emitter()); readonly onDidCreateConnection = this._onDidCreateConnection.event; createdConnectionCount = 0; @@ -113,11 +115,22 @@ class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnec this._entries.set([...this._entries.get(), entry], undefined); } + /** Stages a factory-level rejection, as a failed precondition check would produce. */ + stageFailure(entry: IRemoteAgentHostEntry, error: Error): void { + const address = getEntryAddress(entry); + this._failures.set(address, [...(this._failures.get(address) ?? []), error]); + this._entries.set([...this._entries.get(), entry], undefined); + } + createConnection(entry: IRemoteAgentHostEntry): Promise { if (entry.connection.type !== this.kind) { return Promise.reject(new Error(`Test factory cannot create a ${entry.connection.type} connection.`)); } const address = getEntryAddress(entry); + const failure = this._failures.get(address)?.shift(); + if (failure) { + return Promise.reject(failure); + } const connection = this._createdConnections.get(address)?.shift(); if (!connection) { return Promise.reject(new Error(`No test connection staged for ${address}.`)); @@ -406,10 +419,10 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); const entry = service.connections.find(c => c.address === 'host1:8080'); assert.ok(entry); - assert.strictEqual(entry.status, RemoteAgentHostConnectionStatus.disconnected); + assert.deepStrictEqual(entry.status, RemoteAgentHostConnectionStatus.disconnected); }); - test('removes connection on connect failure', async () => { + test('retains an unknown disconnected entry on connect failure', async () => { configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]); await waitForCreatedClients(1); assert.strictEqual(createdClients.length, 1); @@ -419,8 +432,15 @@ suite('RemoteAgentHostService', () => { createdClients[0].connectDeferred.error(new Error('Connection refused')); await connectionChanged; - assert.strictEqual(service.connections.length, 0); - assert.strictEqual(service.getConnection('ws://bad:9999'), undefined); + assert.deepStrictEqual({ + connection: service.getConnection('ws://bad:9999'), + clientId: service.connections.find(connection => connection.address === 'bad:9999')?.clientId, + status: service.connections.find(connection => connection.address === 'bad:9999')?.status, + }, { + connection: undefined, + clientId: undefined, + status: RemoteAgentHostConnectionStatus.disconnected, + }); }); test('manages multiple connections independently', async () => { @@ -829,6 +849,62 @@ suite('RemoteAgentHostService', () => { }); }); + test('retains a client-less disconnected entry when the factory rejects before a client exists', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const address = getEntryAddress(entry); + + // A stopped distro is rejected by the factory's precondition check before + // any protocol client exists — the path a real WSL outage actually takes. + // The status must survive with no client, or the UI cannot tell a + // resolvable outage from a generic one and offers no recovery action. + factory.stageFailure(entry, new NonReconnectableTransportError(`WSL distro 'Ubuntu' is not running.`, AgentHostTransportFailureReason.HostNotRunning)); + while (service.connections.find(connection => connection.address === address)?.status.kind !== 'disconnected') { + await Event.toPromise(service.onDidChangeConnections); + } + + const info = service.connections.find(connection => connection.address === address); + assert.deepStrictEqual({ + connection: service.getConnection(address), + clientId: info?.clientId, + status: info?.status, + }, { + connection: undefined, + clientId: undefined, + status: RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning), + }); + }); + + test('surfaces a stopped WSL distro as a host-not-running disconnect and clears the reason once it reconnects', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const client = new MockProtocolClient('wsl:Ubuntu'); + await reconnectStagedConnection(factory, entry, client); + + const changed = Event.toPromise(service.onDidChangeConnections); + client.fireClose(AgentHostTransportFailureReason.HostNotRunning); + await changed; + + const afterClose = service.connections.find(connection => connection.address === 'wsl:Ubuntu')?.status; + + // A host that comes back must stop claiming it is not running. + await reconnectStagedConnection(factory, entry, new MockProtocolClient('wsl:Ubuntu')); + + assert.deepStrictEqual({ + afterClose, + statusAfterReconnect: service.connections.find(connection => connection.address === 'wsl:Ubuntu')?.status, + }, { + afterClose: RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning), + statusAfterReconnect: RemoteAgentHostConnectionStatus.connected, + }); + }); + test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => { const factory = createFactory(); const t = makeTransportDisposable(); diff --git a/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts index 95ea58912ec342..e0bea7779f9130 100644 --- a/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts @@ -12,7 +12,7 @@ import { runWithFakedTimers } from '../../../../base/test/common/timeTravelSched import { NullLogService } from '../../../log/common/log.js'; import type { IProductService } from '../../../product/common/productService.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import type { IWSLConnectResult } from '../../common/wslRemoteAgentHost.js'; +import type { IWSLConnectProgress, IWSLConnectResult } from '../../common/wslRemoteAgentHost.js'; import { WSLRemoteAgentHostMainService } from '../../node/wslRemoteAgentHostService.js'; import type WebSocket from 'ws'; @@ -129,7 +129,7 @@ suite('WSL Remote Agent Host Service', () => { ); }); - test('keeps a chatty bootstrap alive past the output-idle timeout', async () => { + test('accepts initial bootstrap output after the output-idle budget', async () => { return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { const service = disposables.add(createService()); const connect = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }); @@ -137,9 +137,7 @@ suite('WSL Remote Agent Host Service', () => { await Promise.resolve(); const child = service.children[0]; - await timeout(59_000); - child.emitStdout('Downloading server 50%\n'); - await timeout(59_000); + await timeout(60_001); child.emitStdout('ws://127.0.0.1:3000?tkn=token\n'); const result = await connect; @@ -150,7 +148,7 @@ suite('WSL Remote Agent Host Service', () => { }); }); - test('fails a silent bootstrap after the output-idle timeout', async () => { + test('fails a silent bootstrap after the initial-output startup budget', async () => { return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { const service = disposables.add(createService()); const rejected = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }).then( @@ -160,11 +158,61 @@ suite('WSL Remote Agent Host Service', () => { service.resolvePlatform(); await Promise.resolve(); + await timeout(180_001); + const result = await rejected; + + assert.ok(result instanceof Error); + assert.match(result.message, /180000ms startup budget/); + }); + }); + + test('fails after output goes quiet for the output-idle budget', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const service = disposables.add(createService()); + const rejected = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }).then( + result => result, + error => error instanceof Error ? error : new Error(String(error)), + ); + service.resolvePlatform(); + await Promise.resolve(); + + service.children[0].emitStdout('Downloading server 50%\n'); await timeout(60_001); const result = await rejected; assert.ok(result instanceof Error); - assert.match(result.message, /no output for 60000ms/); + assert.match(result.message, /60000ms output-idle budget after output started/); + }); + }); + + test('reports redacted, throttled server download progress', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const service = disposables.add(createService()); + const progress: IWSLConnectProgress[] = []; + disposables.add(service.onDidReportConnectProgress(update => progress.push(update))); + const connect = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }); + service.resolvePlatform(); + await Promise.resolve(); + + const child = service.children[0]; + child.emitStdout('bootstrap shell noise\n'); + for (const percentage of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) { + child.emitStdout(`Downloading server: ${percentage}/100 (${percentage}%) tkn=bootstrap-token\n`); + } + await timeout(250); + child.emitStdout('Downloading server: 99/100 (99%) tkn=bootstrap-token\n'); + child.emitStdout('ws://127.0.0.1:3000?tkn=token\n'); + await connect; + + assert.deepStrictEqual({ + downloadMessages: progress.filter(update => update.message.startsWith('Downloading server')).map(update => update.message), + hasNoise: progress.some(update => update.message === 'bootstrap shell noise'), + hasToken: progress.some(update => update.message.includes('bootstrap-token') || update.message.includes('tkn=token')), + }, { + downloadMessages: ['Downloading server (10%)', 'Downloading server (100%)', 'Downloading server (99%)'], + hasNoise: false, + hasToken: false, + }); }); }); }); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 69a62db57d14b7..1d52a2c1e306b5 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -84,6 +84,8 @@ An `ISession` has a provider-owned resource URI, provider identifier, session ty Consumers derive state from those observables. Provider events announce catalog membership changes; they are not a parallel state store. +Sessions backed by a remote agent host may expose `remoteConnectionStatus`, derived from their backing provider; it is absent when the session has no remote host. Its session-facing disconnected variant may include a machine-readable failure reason. + Providers may expose immutable creation provenance when a session was created by another session. `createdBySession` identifies the creating session and may also identify its chat and turn. The reference is observable so list presentation can diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 8f8bf7ba88f817..b6db953ae21d6e 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -7,7 +7,7 @@ import { $, size, trackFocus } from '../../../base/browser/dom.js'; import { ISerializableView, IViewSize } from '../../../base/browser/ui/grid/grid.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent } from '../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; @@ -19,7 +19,9 @@ import { IActiveSession } from '../../services/sessions/common/sessionsManagemen import { UNARCHIVE_SESSION_COMMAND_ID } from '../../common/sessionCommands.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ChatCompositeBar, IChatCompositeBarDelegate } from './chatCompositeBar.js'; -import { SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; +import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from './remoteHostUnavailableEmptyState.js'; +import { SessionRemoteConnection } from './sessionRemoteConnection.js'; +import { ISessionReadOnlyBannerContent, SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js'; /** @@ -59,6 +61,11 @@ export interface IChatGroupContext { onTabDragEnd(): void; } +interface IChatGroupSurface { + readonly banner: ISessionReadOnlyBannerContent | undefined; + readonly recovery: IRemoteHostUnavailableEmptyStateContent | undefined; +} + /** * A single leaf in the {@link ChatGroupsView} grid. Hosts a * {@link ChatCompositeBar} (this group's chats) on top of a kind-switched @@ -86,9 +93,11 @@ export class ChatGroupView extends Disposable implements ISerializableView { private readonly _barContainer: HTMLElement; private readonly _readOnlyBanner: SessionReadOnlyBanner; private readonly _contentContainer: HTMLElement; + private readonly _remoteHostUnavailableEmptyState: RemoteHostUnavailableEmptyState; private readonly _currentView = this._register(new MutableDisposable()); private readonly _contextDisposables = this._register(new DisposableStore()); + private readonly _connection = this._register(this._instantiationService.createInstance(SessionRemoteConnection)); /** The configured wording for the archive/unarchive action (Archive vs Delete). */ private readonly _archiveActionWording: IObservable>; @@ -106,7 +115,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { constructor( @IChatViewFactory private readonly _chatViewFactory: IChatViewFactory, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService configurationService: IConfigurationService, ) { @@ -121,17 +130,18 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._barContainer = $('.chat-group-view-bar'); this.element.appendChild(this._barContainer); - this._compositeBar = this._register(instantiationService.createInstance(ChatCompositeBar, undefined)); + this._compositeBar = this._register(this._instantiationService.createInstance(ChatCompositeBar, undefined)); this._barContainer.appendChild(this._compositeBar.element); - // Read-only status banner, shown flush below this group's tab bar when the - // group's active chat is non-interactive, in place of the composer which - // is hidden for read-only chats. + // Single status banner, shown flush below this group's tab bar when the + // active chat is non-interactive or its remote host is unavailable. this._readOnlyBanner = this._register(new SessionReadOnlyBanner()); this._barContainer.appendChild(this._readOnlyBanner.domNode); this._contentContainer = $('.chat-group-view-content'); this.element.appendChild(this._contentContainer); + this._remoteHostUnavailableEmptyState = this._register(new RemoteHostUnavailableEmptyState()); + this._contentContainer.appendChild(this._remoteHostUnavailableEmptyState.domNode); this._register(this._compositeBar.onDidChangeVisibility(() => this._layoutChildren())); this._register(this._compositeBar.onDidChangeHeight(() => this._layoutChildren())); @@ -156,11 +166,13 @@ export class ChatGroupView extends Disposable implements ISerializableView { /** Sets (or clears) the group this view renders. */ setContext(context: IChatGroupContext | undefined): void { this._contextDisposables.clear(); + this._connection.setSession(context?.session); if (!context) { this._compositeBar.setGroup(undefined); this._currentView.clear(); - this._contentContainer.replaceChildren(); + this._setRemoteHostUnavailableEmptyState(undefined); + this._contentContainer.replaceChildren(this._remoteHostUnavailableEmptyState.domNode); return; } @@ -181,6 +193,44 @@ export class ChatGroupView extends Disposable implements ISerializableView { const activeResource = context.activeChatResource.read(reader); return context.chats.read(reader).find(c => c.resource.toString() === activeResource); }); + const currentView = observableValue(this._contextDisposables, this._currentView.value); + + const readOnlyContent = derived(reader => { + const chat = activeChat.read(reader); + if (!chat || chat.interactivity.read(reader) === ChatInteractivity.Full) { + return undefined; + } + + const archived = context.session.isArchived.read(reader); + if (archived) { + const action = getChatSessionArchiveActionPresentation(this._archiveActionWording.read(reader)).unarchive; + return { + message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."), + action: { + label: action.title.value, + run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, context.session), + }, + }; + } + return { message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }; + }); + + const surface = derived(reader => { + const readOnly = readOnlyContent.read(reader); + if (readOnly) { + return { banner: readOnly, recovery: undefined }; + } + + const view = currentView.read(reader); + const recovery = view?.hasVisibleTranscriptContent.read(reader) + ? undefined + : this._connection.recoveryContent.read(reader); + if (recovery) { + return { banner: undefined, recovery }; + } + + return { banner: this._connection.bannerContent.read(reader), recovery: undefined }; + }); this._contextDisposables.add(autorun(reader => { const session = context.session; @@ -202,8 +252,9 @@ export class ChatGroupView extends Disposable implements ISerializableView { view = desiredKind === 'chat' ? this._chatViewFactory.createChatView() : this._chatViewFactory.createNewChatView(desiredKind === 'newChatInSession', context.options); - this._contentContainer.replaceChildren(view.element); + this._contentContainer.replaceChildren(view.element, this._remoteHostUnavailableEmptyState.domNode); this._currentView.value = view; + currentView.set(view, undefined); view.setActive(this._sessionActive); view.setVisible(this._sessionVisible); this._layoutChildren(); @@ -213,34 +264,29 @@ export class ChatGroupView extends Disposable implements ISerializableView { view.setChat(chat, session.sessionId, session); } - // Show the read-only banner in place of the composer when the group's - // active chat is non-interactive (e.g. a subagent transcript or an - // archived session). - const readOnly = !!chat && chat.interactivity.read(reader) !== ChatInteractivity.Full; - if (readOnly) { - const archived = session.isArchived.read(reader); - if (archived) { - const action = getChatSessionArchiveActionPresentation(this._archiveActionWording.read(reader)).unarchive; - this._readOnlyBanner.setContent({ - message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."), - action: { - label: action.title.value, - run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, session), - }, - }); - } else { - this._readOnlyBanner.setContent({ message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }); - } - } - // Only re-layout when the banner's visibility (and thus its - // contribution to the bar height) actually changes. - if (this._readOnlyBanner.visible !== readOnly) { - this._readOnlyBanner.setVisible(readOnly); - this._layoutChildren(); - } + const surfaceContent = surface.read(reader); + this._setRemoteHostUnavailableEmptyState(surfaceContent.recovery); + this._setReadOnlyBanner(surfaceContent.banner); })); } + private _setReadOnlyBanner(content: ISessionReadOnlyBannerContent | undefined): void { + if (content) { + this._readOnlyBanner.setContent(content); + } + // Only re-layout when the banner's visibility (and thus its + // contribution to the bar height) actually changes. + if (this._readOnlyBanner.visible !== !!content) { + this._readOnlyBanner.setVisible(!!content); + this._layoutChildren(); + } + } + + private _setRemoteHostUnavailableEmptyState(content: IRemoteHostUnavailableEmptyStateContent | undefined): void { + this._remoteHostUnavailableEmptyState.setContent(content); + this._contentContainer.classList.toggle('remote-host-unavailable', !!content); + } + /** Whether this group is the active (focused) group within the session. */ setGroupActive(active: boolean): void { this._groupActive = active; @@ -307,6 +353,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { const bannerHeight = this._readOnlyBanner.visible ? this._readOnlyBanner.domNode.offsetHeight : 0; const barHeight = tabsHeight + bannerHeight; size(this._barContainer, width, barHeight); + size(this._contentContainer, width, height - barHeight); this._currentView.value?.layout(width, height - barHeight, top + barHeight, left); } @@ -320,6 +367,10 @@ export class ChatGroupView extends Disposable implements ISerializableView { } focus(): void { + if (this._remoteHostUnavailableEmptyState.visible) { + this._remoteHostUnavailableEmptyState.focus(); + return; + } this._currentView.value?.focus(); } } diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 12b4fa219c7b80..b3621e13ea8a15 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -8,6 +8,7 @@ import { ISerializableView, IViewSize } from '../../../base/browser/ui/grid/grid import { ProgressBar } from '../../../base/browser/ui/progressbar/progressbar.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { constObservable, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { defaultProgressBarStyles } from '../../../platform/theme/browser/defaultStyles.js'; import { IProgressScope, ScopedProgressIndicator } from '../../../workbench/services/progress/browser/progressIndicator.js'; @@ -60,6 +61,12 @@ export abstract class AbstractChatView extends Disposable implements ISerializab */ abstract readonly kind: ChatViewKind; + /** + * Whether the view has a visible transcript turn to retain when a remote + * host disconnects. New and unbound views intentionally report no content. + */ + readonly hasVisibleTranscriptContent: IObservable = constObservable(false); + /** * Show the given chat in this view. The default implementation is a * no-op; subclasses that host a chat widget (e.g. `ChatView`) override diff --git a/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css new file mode 100644 index 00000000000000..a60ba4307fd6f3 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.remote-host-unavailable-empty-state { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + gap: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size240) var(--vscode-spacing-size160); + color: var(--session-view-foreground); + text-align: center; +} + +.remote-host-unavailable-empty-state.hidden, +.remote-host-unavailable-empty-state-progress.hidden, +.remote-host-unavailable-empty-state-action.hidden { + display: none; +} + +.remote-host-unavailable-empty-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--vscode-descriptionForeground); +} + +.remote-host-unavailable-empty-state-icon .codicon { + font-size: var(--vscode-codiconFontSize); +} + +.remote-host-unavailable-empty-state-title { + max-width: var(--session-view-centered-content-max-width); + margin: 0; + color: var(--session-view-foreground); + font-size: var(--vscode-fontSize-heading2); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 1.4; +} + +.remote-host-unavailable-empty-state-description, +.remote-host-unavailable-empty-state-progress { + max-width: var(--session-view-centered-content-max-width); + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); + line-height: 1.4; +} + +.remote-host-unavailable-empty-state-action { + margin-top: var(--vscode-spacing-size80); +} + +.chat-group-view-content.remote-host-unavailable > .chat-view { + visibility: hidden; + pointer-events: none; +} diff --git a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts new file mode 100644 index 00000000000000..036fe1202a0997 --- /dev/null +++ b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/remoteHostUnavailableEmptyState.css'; +import * as dom from '../../../base/browser/dom.js'; +import { renderIcon } from '../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Button } from '../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { defaultButtonStyles } from '../../../platform/theme/browser/defaultStyles.js'; + +export interface IRemoteHostUnavailableEmptyStateContent { + readonly title: string; + readonly description: string; + readonly progress?: string; + readonly action?: { + readonly label: string; + readonly run: () => void; + }; +} + +/** + * Blocking recovery state for a new chat whose remote host is unavailable. + */ +export class RemoteHostUnavailableEmptyState extends Disposable { + + readonly domNode: HTMLElement; + private readonly _title: HTMLElement; + private readonly _description: HTMLElement; + private readonly _progress: HTMLElement; + private readonly _actionContainer: HTMLElement; + private readonly _action: Button; + private readonly _actionListener = this._register(new MutableDisposable()); + + constructor() { + super(); + + this.domNode = dom.$('.remote-host-unavailable-empty-state.hidden'); + this.domNode.setAttribute('role', 'group'); + this.domNode.tabIndex = -1; + + const icon = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-icon')); + icon.setAttribute('aria-hidden', 'true'); + icon.appendChild(renderIcon(Codicon.debugDisconnect)); + + this._title = dom.append(this.domNode, dom.$('h2.remote-host-unavailable-empty-state-title')); + this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description')); + this._progress = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-progress.hidden')); + this._actionContainer = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-action.hidden')); + // Primary styling: recovering the host is the one thing to do here. + this._action = this._register(new Button(this._actionContainer, { ...defaultButtonStyles, title: true })); + } + + get visible(): boolean { + return !this.domNode.classList.contains('hidden'); + } + + setContent(content: IRemoteHostUnavailableEmptyStateContent | undefined): void { + this._actionListener.clear(); + this.domNode.classList.toggle('hidden', !content); + if (!content) { + this._progress.textContent = ''; + this._progress.classList.add('hidden'); + this._actionContainer.classList.add('hidden'); + this._action.label = ''; + return; + } + + this.domNode.setAttribute('aria-label', content.title); + this._title.textContent = content.title; + this._description.textContent = content.description; + this._progress.textContent = content.progress ?? ''; + this._progress.classList.toggle('hidden', !content.progress); + if (!content.action) { + this._actionContainer.classList.add('hidden'); + this._action.label = ''; + return; + } + + this._actionContainer.classList.remove('hidden'); + this._action.label = content.action.label; + this._action.setAriaLabel(content.action.label); + this._actionListener.value = this._action.onDidClick(() => content.action?.run()); + } + + focus(): void { + this.domNode.focus(); + } + + override dispose(): void { + this.domNode.remove(); + super.dispose(); + } +} diff --git a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts index 2b697c26535f86..4907ae90af98f2 100644 --- a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts +++ b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts @@ -10,25 +10,25 @@ import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { Codicon } from '../../../base/common/codicons.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; import { localize } from '../../../nls.js'; /** - * Content shown by a {@link SessionReadOnlyBanner}: a message and an optional - * inline action (e.g. "Restore" for an archived session). The action's callback - * is supplied by the owner so the banner stays purely presentational. + * Content shown by a {@link SessionReadOnlyBanner}: an optional icon, message, + * and optional inline action. The action's callback is supplied by the owner so + * the banner stays purely presentational. */ export interface ISessionReadOnlyBannerContent { + readonly icon?: ThemeIcon; readonly message: string; readonly action?: { readonly label: string; readonly run: () => void }; } /** - * A small, self-contained status banner that indicates the current chat is - * read-only (non-interactive). Mirrors the read-only editor banner in VS Code: - * a subtle full-width bar with a leading icon and a single line of text. Shown - * in place of the composer for read-only chats (e.g. a subagent's transcript, - * or an archived session), where it explains why there is no input and — when - * the owner supplies one — offers an inline action to make it interactive again. + * A small, self-contained status banner for the current chat. It mirrors the + * read-only editor banner: a subtle full-width bar with a leading icon and one + * line of text. It can explain both a non-interactive chat and a remote-host + * state without adding another bar to the group. * * Purely presentational: visibility is driven by the owning chat view via * {@link setVisible} and its content via {@link setContent}. @@ -39,6 +39,7 @@ export class SessionReadOnlyBanner extends Disposable { private _visible = false; + private readonly _icon: HTMLElement; private readonly _text: HTMLElement; private readonly _actionContainer: HTMLElement; private readonly _actionDisposables = this._register(new DisposableStore()); @@ -52,8 +53,8 @@ export class SessionReadOnlyBanner extends Disposable { // override the accessible name without changing the announcement). this.domNode.setAttribute('role', 'status'); - const icon = dom.append(this.domNode, dom.$('.session-readonly-banner-icon')); - icon.appendChild(renderIcon(Codicon.lock)); + this._icon = dom.append(this.domNode, dom.$('.session-readonly-banner-icon')); + this._icon.setAttribute('aria-hidden', 'true'); this._text = dom.append(this.domNode, dom.$('span.session-readonly-banner-text')); this._actionContainer = dom.append(this.domNode, dom.$('span.session-readonly-banner-action')); @@ -72,6 +73,8 @@ export class SessionReadOnlyBanner extends Disposable { } setContent(content: ISessionReadOnlyBannerContent): void { + dom.clearNode(this._icon); + this._icon.appendChild(renderIcon(content.icon ?? Codicon.lock)); this._text.textContent = content.message; this._actionDisposables.clear(); @@ -96,4 +99,3 @@ export class SessionReadOnlyBanner extends Disposable { } } } - diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts new file mode 100644 index 00000000000000..f85c9068a3fe10 --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -0,0 +1,256 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TimeoutTimer } from '../../../base/common/async.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { onUnexpectedError } from '../../../base/common/errors.js'; +import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue } from '../../../base/common/observable.js'; +import { localize } from '../../../nls.js'; +import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; +import { SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../services/sessions/common/session.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; +import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js'; +import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js'; + +const RECONNECTING_BANNER_DELAY = 1_000; + +type ConnectAttempt = + | { readonly kind: 'active'; readonly session: IActiveSession; readonly message: string | undefined } + | { readonly kind: 'failed'; readonly session: IActiveSession; readonly statusBefore: SessionRemoteConnectionStatus | undefined }; + +/** + * Connection presentation and recovery state for the session rendered by a chat group. + */ +export class SessionRemoteConnection extends Disposable { + + private readonly _session = observableValue(this, undefined); + private readonly _attempt = observableValue(this, undefined); + private readonly _progressListener = this._register(new MutableDisposable()); + private readonly _deadlineSignal = observableSignal(this); + + private readonly _reconnectingSince = derivedObservableWithCache<{ readonly session: IActiveSession; readonly since: number } | undefined>(this, (reader, last) => { + const session = this._session.read(reader); + const status = this._getEffectiveStatus(reader); + const attempt = this._attempt.read(reader); + if (!session || status?.kind !== 'reconnecting' || attempt?.kind === 'active') { + return undefined; + } + // Keyed by session only to guard future reuse: a ChatGroupView is currently + // created per session, so the cache cannot outlive the session it belongs to. + return last?.session === session ? last : { session, since: Date.now() }; + }); + + private readonly _reconnectingBannerVisible = derived(this, reader => { + const reconnecting = this._reconnectingSince.read(reader); + if (reconnecting === undefined) { + return false; + } + + const remaining = reconnecting.since + RECONNECTING_BANNER_DELAY - Date.now(); + if (remaining <= 0) { + return true; + } + + this._deadlineSignal.read(reader); + // The fixed deadline prevents unrelated observable updates from restarting the delay. + reader.store.add(new TimeoutTimer(() => this._deadlineSignal.trigger(undefined), remaining)); + return false; + }); + + readonly bannerContent: IObservable = derived(this, reader => this._getRemoteConnectionBannerContent(reader)); + readonly recoveryContent: IObservable = derived(this, reader => this._getRemoteHostUnavailableContent(reader)); + + constructor( + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + } + + setSession(session: IActiveSession | undefined): void { + this._progressListener.clear(); + this._attempt.set(undefined, undefined); + this._session.set(session, undefined); + } + + connect(): void { + const session = this._session.get(); + if (!session) { + return; + } + + const attempt = this._attempt.get(); + if (attempt?.kind === 'active' && attempt.session === session) { + return; + } + if (attempt?.kind === 'failed') { + this._attempt.set(undefined, undefined); + } + + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider) || !provider.connect) { + return; + } + + const statusBefore = session.remoteConnectionStatus?.get(); + this._attempt.set({ kind: 'active', session, message: undefined }, undefined); + const remoteAddress = provider.remoteAddress; + if (remoteAddress && provider.onDidReportConnectProgress) { + this._progressListener.value = provider.onDidReportConnectProgress(progress => { + if (progress.connectionKey !== remoteAddress) { + return; + } + const currentAttempt = this._attempt.get(); + if (currentAttempt?.kind === 'active' && currentAttempt.session === session) { + this._attempt.set({ ...currentAttempt, message: progress.message }, undefined); + } + }); + } + + void this._runConnect(session, statusBefore, () => provider.connect!()); + } + + private async _runConnect(session: IActiveSession, statusBefore: SessionRemoteConnectionStatus | undefined, connect: () => Promise): Promise { + try { + await connect(); + this._finishAttempt(session); + } catch (error) { + this._failAttempt(session, statusBefore); + onUnexpectedError(error); + } + } + + private _finishAttempt(session: IActiveSession): void { + const attempt = this._attempt.get(); + if (attempt?.kind !== 'active' || attempt.session !== session) { + return; + } + + this._progressListener.clear(); + this._attempt.set(undefined, undefined); + } + + private _failAttempt(session: IActiveSession, statusBefore: SessionRemoteConnectionStatus | undefined): void { + const attempt = this._attempt.get(); + if (attempt?.kind !== 'active' || attempt.session !== session) { + return; + } + + this._progressListener.clear(); + this._attempt.set({ kind: 'failed', session, statusBefore }, undefined); + } + + private _getEffectiveStatus(reader: IReader): SessionRemoteConnectionStatus | undefined { + const session = this._session.read(reader); + const status = session?.remoteConnectionStatus?.read(reader); + const attempt = this._attempt.read(reader); + return attempt?.kind === 'failed' && attempt.session === session && status?.kind === 'connecting' + ? attempt.statusBefore + : status; + } + + private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, reader: IReader): string | undefined { + const attempt = this._attempt.read(reader); + if (attempt?.kind !== 'active' || attempt.session !== session) { + return undefined; + } + return attempt.message + ?? (status?.kind === 'connecting' || status?.kind === 'reconnecting' + ? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection...") + : undefined); + } + + private _getRemoteHostUnavailableContent(reader: IReader): IRemoteHostUnavailableEmptyStateContent | undefined { + const session = this._session.read(reader); + if (!session) { + return undefined; + } + + const status = this._getEffectiveStatus(reader); + const provider = this._sessionsProvidersService.getProvider(session.providerId); + const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); + if (status?.kind === 'connected') { + return undefined; + } + if (status?.kind === 'incompatible') { + return { + title: localize('sessionRemoteHost.incompatibleTitle', "Cannot Connect to {0}", hostLabel), + description: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + }; + } + const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); + if (progressMessage) { + return { + title: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel), + description: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel), + progress: progressMessage, + }; + } + if (!status || status.kind !== 'disconnected') { + return undefined; + } + if (status.reason === SessionRemoteConnectionFailureReason.HostNotRunning) { + return { + title: localize('sessionRemoteHost.notRunningTitle', "Unable to Connect to {0}", hostLabel), + description: localize('sessionRemoteHost.notRunning', "{0} is not running.", hostLabel), + action: provider && isAgentHostProvider(provider) && provider.connect + ? { + label: localize('sessionRemoteHost.start', "Start {0}", hostLabel), + run: () => this.connect(), + } + : undefined, + }; + } + return { + title: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel), + description: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + }; + } + + private _getRemoteConnectionBannerContent(reader: IReader): ISessionReadOnlyBannerContent | undefined { + const session = this._session.read(reader); + if (!session) { + return undefined; + } + + const status = this._getEffectiveStatus(reader); + const provider = this._sessionsProvidersService.getProvider(session.providerId); + const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); + const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); + if (progressMessage) { + return { icon: Codicon.sync, message: progressMessage }; + } + + if (!status || status.kind === 'connected' || status.kind === 'connecting' || status.kind === 'incompatible') { + return undefined; + } + + if (status.kind === 'reconnecting') { + return this._reconnectingBannerVisible.read(reader) + ? { + icon: Codicon.sync, + message: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel), + } + : undefined; + } + if (status.reason === SessionRemoteConnectionFailureReason.HostNotRunning) { + return { + icon: Codicon.debugDisconnect, + message: localize('sessionRemoteHost.notRunning', "{0} is not running.", hostLabel), + action: provider && isAgentHostProvider(provider) && provider.connect + ? { + label: localize('sessionRemoteHost.start', "Start {0}", hostLabel), + run: () => this.connect(), + } + : undefined, + }; + } + return { + icon: Codicon.debugDisconnect, + message: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + }; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 772a21f6d48e86..84ceff4948e607 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -174,6 +174,7 @@ export class ChatView extends AbstractChatView { private _currentChatResource: URI | undefined; private readonly _currentChatResourceObs = observableValue(this, undefined); private readonly _currentSessionObs = observableValue(this, undefined); + override readonly hasVisibleTranscriptContent = observableValue(this, false); private _historyKey: string | undefined; /** Whether this view currently represents the active session. */ @@ -305,6 +306,10 @@ export class ChatView extends AbstractChatView { })); this._register(chatPillsDebugService.register(this._chatPills, this._banners, this._isActiveObs)); this._ensureBannersMounted(); + this._register(this.chatSessionsService.onDidChangeContentProviderSchemes(({ added }) => { + // Remote providers are registered after their host connects, possibly after this view's initial load. + this._retryUnresolvedChatLoad(added); + })); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING)) { @@ -370,6 +375,8 @@ export class ChatView extends AbstractChatView { const model = chatModel.read(reader); model?.lastRequestObs.read(reader); const requests = model?.getRequests() ?? []; + const hasVisibleRequest = requests.some(request => !request.isHiddenFromTranscript); + this.hasVisibleTranscriptContent.set(hasVisibleRequest, undefined); const entry = findInitialTranscriptContextEntry(requests); if (entry?.id === currentEntryId) { return; @@ -439,10 +446,23 @@ export class ChatView extends AbstractChatView { return; } + this.hasVisibleTranscriptContent.set(false, undefined); this._currentChatResource = resource; this._currentChatResourceObs.set(resource, undefined); this.logService.trace(`[ChatView] setChat start uri=${resource.toString()} session=${session?.resource.toString()}`); + this._loadChat(resource, session, previousChatResource, previousSession); + } + + private _retryUnresolvedChatLoad(addedSessionTypes: readonly string[]): void { + const resource = this._currentChatResource; + if (!resource || this._modelRef.value || !addedSessionTypes.includes(getChatSessionType(resource))) { + return; + } + this._loadChat(resource, this._currentSessionObs.get()); + } + + private _loadChat(resource: URI, session: ISession | undefined, previousChatResource?: URI, previousSession?: ISession): void { // Cancel any in-flight load for the previous chat and start a fresh one. this._loadCts.value?.cancel(); if (previousChatResource) { @@ -459,12 +479,13 @@ export class ChatView extends AbstractChatView { const loadPromise = this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatView').then(ref => { const isCurrentChat = isEqual(this._currentChatResource, resource); - if (token.isCancellationRequested || !ref || !isCurrentChat) { + const isCurrentLoad = this._loadCts.value === cts; + if (token.isCancellationRequested || !ref || !isCurrentChat || !isCurrentLoad) { ref?.dispose(); - if (!token.isCancellationRequested && !ref && isCurrentChat && session) { + if (!token.isCancellationRequested && !ref && isCurrentChat && isCurrentLoad && session) { this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); } - if (isCurrentChat) { + if (isCurrentChat && isCurrentLoad) { this._widget.setLoading(false); } this.logService.trace(`[ChatView] setChat abandoned uri=${resource.toString()}`); @@ -494,12 +515,12 @@ export class ChatView extends AbstractChatView { } else { this.logService.trace(`[ChatView] setChat cancelled uri=${resource.toString()}`); } - if (isEqual(this._currentChatResource, resource)) { // might have changed while we were waiting, only reset if it is still the same + if (isEqual(this._currentChatResource, resource) && this._loadCts.value === cts) { // might have changed while we were waiting, only reset if it is still the same this._currentChatResource = undefined; this._currentChatResourceObs.set(undefined, undefined); this._widget.setLoading(false); } - if (!token.isCancellationRequested && session) { + if (!token.isCancellationRequested && this._loadCts.value === cts && session) { this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); } }); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index a161588cb102ae..51a09a38f89071 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -16,7 +16,7 @@ import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/ch import { ResponseModelState } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; -import { findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; +import { ChatView, findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; @@ -29,6 +29,27 @@ suite('Sessions - Chat View', () => { _renderSubSessionTip(): void; } + test('retries an unresolved chat when its content provider is registered', () => { + const resource = URI.parse('remote-agent:/session'); + const loads: URI[] = []; + const modelRef = { value: undefined as object | undefined }; + const view = Object.assign(Object.create(ChatView.prototype), { + _currentChatResource: resource, + _currentSessionObs: { get: () => undefined }, + _modelRef: modelRef, + _loadChat: (chatResource: URI) => loads.push(chatResource), + }) as { + _retryUnresolvedChatLoad(addedSessionTypes: readonly string[]): void; + }; + + view._retryUnresolvedChatLoad(['other-agent']); + view._retryUnresolvedChatLoad(['remote-agent']); + modelRef.value = {}; + view._retryUnresolvedChatLoad(['remote-agent']); + + assert.deepStrictEqual(loads, [resource]); + }); + test('forwards new chat visibility to the aquarium host', () => { const forwarded: boolean[] = []; const isVisible = observableValue(disposables, true); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 63331ed1fb57a2..cf0040528ac08c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -21,6 +21,8 @@ import { AgentSession, AuthenticateParams, AuthenticateResult, IAgentSessionMeta import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import type { AgentHostUriMapper } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import type { RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostTransportFailureReason } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../platform/agentHost/common/customizationEnablement.js'; import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/annotationsUri.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; @@ -54,7 +56,7 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, IAgentMergeClientState, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionChatCustomization, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, getPresentedArtifacts, linkKey, partitionSessionArtifacts } from './agentHostSessionArtifacts.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -570,6 +572,58 @@ export interface IAgentHostAdapterOptions { readonly mapBackendSessionResource: (resource: URI) => URI; /** `Changeset.changeKind` the Changes view selects by default. Defaults to `branch`. */ readonly defaultChangesetKind?: ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session; + /** Connection state from the backing remote provider, when there is one. */ + readonly connectionStatus?: IObservable; +} + +/** + * Projects the backing provider's transport status onto the session-facing + * shape, preserving the machine-readable disconnect reason so consumers can + * tell a resolvable outage (a stopped host) from a generic one. + * + * Returns `undefined` when the provider has no remote transport, which is how + * a local session reports "no remote host" rather than "host unavailable". + */ +function toSessionRemoteConnectionStatus(owner: object, connectionStatus: IObservable | undefined): IObservable | undefined { + if (!connectionStatus) { + return undefined; + } + return derived(owner, reader => { + const status = connectionStatus.read(reader); + switch (status.kind) { + case 'connected': + case 'connecting': + case 'reconnecting': + case 'incompatible': + return { kind: status.kind }; + case 'disconnected': + switch (status.reason) { + case AgentHostTransportFailureReason.Unknown: + return { kind: status.kind, reason: SessionRemoteConnectionFailureReason.Unknown }; + case AgentHostTransportFailureReason.HostNotRunning: + return { kind: status.kind, reason: SessionRemoteConnectionFailureReason.HostNotRunning }; + } + } + }); +} + +/** + * An active status is only meaningful while the backing agent host can make + * progress. Keep the source status intact so it resumes when the host does, + * but present an error rather than a perpetual activity spinner while it is + * known to be unreachable. + */ +function toPresentedSessionStatus(owner: object, status: IObservable, connectionStatus: IObservable | undefined): IObservable { + if (!connectionStatus) { + return status; + } + return derived(owner, reader => { + const value = status.read(reader); + const connection = connectionStatus.read(reader); + return isActiveSessionStatus(value) && (connection.kind === 'disconnected' || connection.kind === 'incompatible') + ? SessionStatus.Error + : value; + }); } /** @@ -616,7 +670,7 @@ class AdditionalChat extends Disposable { private readonly _interactivity: ISettableObservable; private readonly _isNew: ISettableObservable; - constructor(resource: URI, summary: ChatSummary, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false)) { + constructor(resource: URI, summary: ChatSummary, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false), connectionStatus?: IObservable) { super(); const modifiedAt = summary.modifiedAt ? new Date(summary.modifiedAt) : new Date(); this._title = observableValue('chatTitle', summary.title || localize('newChatTab', "New Chat")); @@ -629,12 +683,13 @@ class AdditionalChat extends Disposable { this._lastTurnEnd = observableValueOpts({ owner: this, debugName: 'chatLastTurnEnd', equalsFn: dateEquals }, modifiedAt); this._interactivity = observableValue('chatInteractivity', toChatInteractivity(summary.interactivity)); this._isNew = observableValue('chatIsNew', isNew); + const status = derived(this, reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)); this.chat = { resource, createdAt: modifiedAt, title: this._title, updatedAt: this._updatedAt, - status: derived(reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)), + status: toPresentedSessionStatus(this, status, connectionStatus), changes: constObservable([]), lastTurnChanges: output?.lastTurnChanges, customizations: output?.customizations, @@ -745,6 +800,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly isQuickChat: IObservable; readonly isAutomation = observableValue('isAutomation', false); readonly isExternal: IObservable; + readonly remoteConnectionStatus: IObservable | undefined; readonly createdBySession: IObservable; /** See {@link ISession.worktreePending}. */ readonly worktreePending: IObservable; @@ -963,6 +1019,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._meta = metadata._meta; this._metaObs = observableValue('agentHostSessionMeta', this._meta); this.isExternal = derived(this, reader => readSessionExternal(this._metaObs.read(reader))); + const connectionStatus = _options.connectionStatus; + this.remoteConnectionStatus = toSessionRemoteConnectionStatus(this, connectionStatus); this.createdBySession = derived(this, reader => { const creationReference = readSessionCreationReference(this._metaObs.read(reader)); if (!creationReference) { @@ -1090,12 +1148,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._sessionOutput = sessionOutput; this._currentTurnChanges = this._createCurrentTurnChangesObservable(); + const defaultChatStatus = derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)); const mainChat: IChat = { resource: this.resource, createdAt: this.createdAt, title: derived(this, reader => this._defaultChatTitleOverride.read(reader) ?? this.title.read(reader)), updatedAt: this.updatedAt, - status: derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)), + status: toPresentedSessionStatus(this, defaultChatStatus, connectionStatus), changes: this.changes, lastTurnChanges: sessionOutput.getLastTurnChanges(URI.parse(buildDefaultChatUri(this.backendUri))), customizations: sessionOutput.getChatCustomizations(URI.parse(buildDefaultChatUri(this.backendUri))), @@ -1266,7 +1325,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { lastTurnChanges: this._sessionOutput.getLastTurnChanges(backendUri), customizations: this._sessionOutput.getChatCustomizations(backendUri), }; - const chat = new AdditionalChat(resource, summary, this._newChatIds.has(chatId), this._resolveParentChatResource(summary.origin), this.isArchived, output, this._options.readOnly); + const chat = new AdditionalChat(resource, summary, this._newChatIds.has(chatId), this._resolveParentChatResource(summary.origin), this.isArchived, output, this._options.readOnly, this._options.connectionStatus); const selection = this._chatModelSelections.get(chatId); if (selection) { chat.setModelId(selection.modelId, selection.source); @@ -2056,6 +2115,7 @@ class NewSession extends Disposable { const authPending = ctx.authenticationPending; const loading = this._loading; const chats = this._mainChat.map(c => [c]); + const connectionStatus = _options.connectionStatus; this.session = { sessionId: `${ctx.providerId}:${resource.toString()}`, resource, @@ -2066,6 +2126,7 @@ class NewSession extends Disposable { workspace: this._workspace, isQuickChat: constObservable(this._kind.isQuickChat), worktreePending: this._worktreePending, + remoteConnectionStatus: toSessionRemoteConnectionStatus(this, connectionStatus), title, updatedAt, status: this._status, @@ -2883,6 +2944,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Provider-level authentication-pending observable used to derive `loading` for sessions. */ protected abstract get authenticationPending(): IObservable; + /** Connection state for remote-host sessions. */ + protected get remoteConnectionStatus(): IObservable | undefined { + return undefined; + } + /** * Subclass-specific portion of the adapter options. Base fills in * the bits that are uniform across hosts (`icon`, `loading`, @@ -2943,6 +3009,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement agentCapabilities: this._agentCapabilities, backendSessionScheme: this._backendSessionScheme(provider), mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), + connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; @@ -3405,6 +3472,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement getConnection: () => this.connection, agentCapabilities: this._agentCapabilities, mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), + connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions); } catch (err) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index bc0bb4fabb8002..a14d279a436da1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -679,6 +679,13 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(provider.sessionTypes[0].label, 'Copilot'); }); + test('leaves remote connection status absent from local sessions', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'local-connection-status'); + + assert.deepStrictEqual(provider.getSessions().map(session => session.remoteConnectionStatus), [undefined]); + }); + test('session types update when the local host advertises additional agents', () => { const provider = createProvider(disposables, agentHost); assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts index 46493a09cbb975..d3182933a849a3 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts @@ -62,14 +62,6 @@ export abstract class EntryDrivenProviderContribution extends Disposable { /** Supplies kind-specific on-demand behavior for an entry's provider. */ protected abstract _getProviderOptions(entry: IRemoteAgentHostEntry): IEntryDrivenProviderOptions; - /** - * Whether a vanished connection should clear the provider's active - * connection. Defaults to false to preserve existing WSL behavior. - */ - protected get _clearConnectionOnRemoval(): boolean { - return false; - } - protected _reconcile(): void { this._reconcileProviders(); this._wireConnections(); @@ -131,11 +123,9 @@ export abstract class EntryDrivenProviderContribution extends Disposable { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { provider.setConnection(connection, connectionInfo.defaultDirectory); - if (this._clearConnectionOnRemoval) { - this._wiredAddresses.add(address); - } + this._wiredAddresses.add(address); } - } else if (this._clearConnectionOnRemoval && !connectionInfo && this._wiredAddresses.delete(address)) { + } else if (this._wiredAddresses.delete(address)) { provider.clearConnection(); } } @@ -146,8 +136,6 @@ export abstract class EntryDrivenProviderContribution extends Disposable { const connectionInfo = this._remoteAgentHostService.connections.find(connection => connection.address === address); if (connectionInfo) { provider.setConnectionStatus(connectionInfo.status); - } else if (!RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { - provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); } } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 2960db3a9a4e1b..293dada061637f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -176,7 +176,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const currentConnections = this._remoteAgentHostService.connections; const connectedAddresses = new Set( currentConnections - .filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status)) + .filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status) && c.clientId !== undefined) .map(c => c.address) ); const allAddresses = new Set(currentConnections.map(c => c.address)); @@ -195,7 +195,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Add or update connections for (const connectionInfo of currentConnections) { // Only set up contribution state for connected entries - if (!RemoteAgentHostConnectionStatus.isConnected(connectionInfo.status)) { + if (!RemoteAgentHostConnectionStatus.isConnected(connectionInfo.status) || connectionInfo.clientId === undefined) { continue; } const existing = this._connections.get(connectionInfo.address); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 75d7f111873a91..2806099590e0cf 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -8,7 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { isWeb } from '../../../../../base/common/platform.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -150,12 +150,22 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _readOnly = observableValue('providerReadOnly', false); readonly connectionStatus: IObservable = this._connectionStatus; + protected override get remoteConnectionStatus(): IObservable { + return this.connectionStatus; + } + /** * `true` while we are still resolving and pushing tokens for the host's * `protectedResources`. Defaults to `true` so that sessions surface as * loading until the first authentication pass settles. */ private readonly _authenticationPending = observableValue('authenticationPending', true); + private readonly _effectiveAuthenticationPending = derived(this, reader => { + const status = this._connectionStatus.read(reader); + return this._authenticationPending.read(reader) + && !RemoteAgentHostConnectionStatus.isDisconnected(status) + && !RemoteAgentHostConnectionStatus.isIncompatible(status); + }); private _authenticationSettled = false; private readonly _onDidDisconnect = this._register(new Emitter()); @@ -382,7 +392,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected get connection(): IAgentConnection | undefined { return this._connection; } - protected get authenticationPending(): IObservable { return this._authenticationPending; } + protected get authenticationPending(): IObservable { return this._effectiveAuthenticationPending; } /** * Suspend cache-change tracking while sessions are unpublished (offline) so diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts index 9169efa9edab13..d8e7432dee8245 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts @@ -59,10 +59,6 @@ export class SSHAgentHostContribution extends ManagedReconnectAgentHostContribut protected readonly _entryType = RemoteAgentHostEntryType.SSH; - protected override get _clearConnectionOnRemoval(): boolean { - return true; - } - constructor( @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @ISSHRemoteAgentHostService private readonly _sshService: ISSHRemoteAgentHostService, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts index fe1f5ced78077e..0ff8eca4dc4a24 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts @@ -17,10 +17,6 @@ export class WebSocketAgentHostContribution extends EntryDrivenProviderContribut protected readonly _entryType = RemoteAgentHostEntryType.WebSocket; - protected override get _clearConnectionOnRemoval(): boolean { - return true; - } - constructor( @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @IConfigurationService configurationService: IConfigurationService, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index e365447d2f700f..3bc1ebb5f511a4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -17,6 +17,8 @@ import { AgentSession, type IAgentSessionMetadata } from '../../../../../../plat import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { RemoteAgentHostConnectionStatus } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostTransportFailureReason } from '../../../../../../platform/agentHost/common/state/sessionTransport.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { MessageKind, SessionLifecycle, type AgentInfo, type AutomationState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -36,7 +38,7 @@ import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, SessionRemoteConnectionFailureReason, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -226,12 +228,13 @@ class MockAgentConnection extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; status?: ProtocolSessionStatus; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { return { session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, + status: opts?.status, project: opts?.project, workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, _meta: opts?._meta, @@ -423,6 +426,53 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(provider.label, 'myhost:9999'); }); + test('derives session remote connection status from the backing provider', () => { + const provider = createProvider(disposables, connection); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + fireSessionAdded(connection, 'connection-status'); + const session = provider.getSessions()[0]; + const statuses = [session.remoteConnectionStatus?.get()]; + + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.reconnecting); + statuses.push(session.remoteConnectionStatus?.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + statuses.push(session.remoteConnectionStatus?.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning)); + statuses.push(session.remoteConnectionStatus?.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.incompatible('Protocol version mismatch', ['1'])); + statuses.push(session.remoteConnectionStatus?.get()); + + assert.deepStrictEqual({ + hasRemoteHost: session.remoteConnectionStatus !== undefined, + statuses, + }, { + hasRemoteHost: true, + statuses: [ + { kind: 'connected' }, + { kind: 'reconnecting' }, + { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }, + { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, + { kind: 'incompatible' }, + ], + }); + }); + + test('does not present an active chat as busy while its remote host is unavailable', () => { + const provider = createProvider(disposables, connection); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + provider.seedSessions([createSession('active-session', { status: ProtocolSessionStatus.InProgress })]); + const session = provider.getSessions()[0]; + const chat = session.mainChat.get(); + const statuses = [chat.status.get()]; + + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning)); + statuses.push(chat.status.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + statuses.push(chat.status.get()); + + assert.deepStrictEqual(statuses, [SessionStatus.InProgress, SessionStatus.Error, SessionStatus.InProgress]); + }); + test('remoteLocationPreferenceKey defaults to the live address when no stable preference key is given (e.g. tunnels/WSL)', () => { const provider = createProvider(disposables, connection, { address: 'tunnel:abc123' }); assert.strictEqual(provider.remoteLocationPreferenceKey, 'tunnel:abc123'); @@ -1060,6 +1110,7 @@ suite('RemoteAgentHostSessionsProvider', () => { test('cached session loading reflects authenticationPending', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.addSession(createSession('cached-auth', { summary: 'Cached' })); const provider = createProvider(disposables, connection); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); await timeout(0); const session = provider.getSessions().find(s => s.title.get() === 'Cached'); @@ -1075,6 +1126,29 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(session!.loading.get(), false); })); + test('cached session loading settles while the host is unavailable but stays pending while it connects', async () => { + connection.addSession(createSession('cached-connection-state', { summary: 'Cached' })); + const provider = createProvider(disposables, connection); + await timeout(0); + + const session = provider.getSessions().find(s => s.title.get() === 'Cached'); + assert.ok(session); + const loading = [session!.loading.get()]; + + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connecting); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.reconnecting); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.incompatible('Protocol version mismatch', ['1'])); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + loading.push(session!.loading.get()); + + assert.deepStrictEqual(loading, [false, true, true, false, false, true]); + }); + test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); connection.addSession(createSession('keep-me', { summary: 'Keep Me' })); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts index 3a07e1d8a6edd0..2f37f36b692f9c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts @@ -7,6 +7,9 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { RemoteAgentHostConnectionStatus } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostTransportFailureReason } from '../../../../../../platform/agentHost/common/state/sessionTransport.js'; import { shouldPauseWSLReconnectAfterFailure, WSLAgentHostContribution } from '../../browser/wslAgentHost.contribution.js'; suite('shouldPauseWSLReconnectAfterFailure', () => { @@ -31,6 +34,37 @@ interface IWSLDisconnectHarness { _disconnectWSLOnDemand(distro: string, address: string): Promise; } +interface IWSLConnectionWiringHarness { + _remoteAgentHostService: { + readonly connections: readonly { + readonly address: string; + readonly defaultDirectory?: string; + readonly status: RemoteAgentHostConnectionStatus; + }[]; + getConnection(address: string): IAgentConnection | undefined; + }; + _providerInstances: Map; + _wiredAddresses: Set; + _wireConnections(): void; +} + +interface IWSLConnectionStatusHarness { + _remoteAgentHostService: { + readonly connections: readonly { + readonly address: string; + readonly status: RemoteAgentHostConnectionStatus; + }[]; + }; + _providerInstances: Map; + _updateConnectionStatuses(): void; +} + suite('WSLAgentHostContribution disconnect', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -62,3 +96,64 @@ suite('WSLAgentHostContribution disconnect', () => { assert.deepStrictEqual(calls, ['state:Ubuntu', 'wsl:Ubuntu', 'remove:wsl:Ubuntu', 'reconcile']); }); }); + +suite('WSLAgentHostContribution connection wiring', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('clears a wired provider when its connection disconnects or vanishes without clearing an unwired provider', () => { + const address = 'wsl:Ubuntu'; + const connection = {} as IAgentConnection; + const calls: string[] = []; + let connections: { address: string; defaultDirectory?: string; status: RemoteAgentHostConnectionStatus }[] = [{ + address, + status: RemoteAgentHostConnectionStatus.connected, + }]; + const contribution = Object.create(WSLAgentHostContribution.prototype) as IWSLConnectionWiringHarness; + contribution._remoteAgentHostService = { + get connections() { return connections; }, + getConnection: requestedAddress => requestedAddress === address ? connection : undefined, + }; + contribution._providerInstances = new Map([ + [address, { + setConnection: () => calls.push('wired:set'), + clearConnection: () => calls.push('wired:clear'), + }], + ['wsl:unwired', { + setConnection: () => calls.push('unwired:set'), + clearConnection: () => calls.push('unwired:clear'), + }], + ]); + contribution._wiredAddresses = new Set(); + + contribution._wireConnections(); + connections = [{ address, defaultDirectory: '/home/ubuntu', status: RemoteAgentHostConnectionStatus.disconnected }]; + contribution._wireConnections(); + connections = []; + contribution._wireConnections(); + + assert.deepStrictEqual({ calls, wiredAddresses: [...contribution._wiredAddresses] }, { + calls: ['wired:set', 'wired:clear'], + wiredAddresses: [], + }); + }); + + test('propagates the status of a failed WSL connection entry', () => { + const address = 'wsl:Ubuntu'; + let status = RemoteAgentHostConnectionStatus.connecting; + const contribution = Object.create(WSLAgentHostContribution.prototype) as IWSLConnectionStatusHarness; + contribution._remoteAgentHostService = { + connections: [{ address, status: RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning) }], + }; + contribution._providerInstances = new Map([[ + address, + { + connectionStatus: { get: () => status }, + setConnectionStatus: nextStatus => { status = nextStatus; }, + }, + ]]); + + contribution._updateConnectionStatuses(); + + assert.deepStrictEqual(status, RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning)); + }); +}); diff --git a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts index 8e190ac3315f60..a7cb0b3938143c 100644 --- a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts +++ b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts @@ -53,10 +53,13 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { ) { } canRun(session: ISession): boolean { - return this._getAddress(session) !== undefined; + return this._isSessionRemoteHostAvailable(session) && this._getAddress(session) !== undefined; } async runTask(task: ITaskEntry, session: ISession): Promise { + if (!this._isSessionRemoteHostAvailable(session)) { + return undefined; + } const address = this._getAddress(session); if (!address) { return undefined; @@ -81,6 +84,9 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { return undefined; } + if (!this._isSessionRemoteHostAvailable(session)) { + return undefined; + } const instance = await this._agentHostTerminalService.createTerminalForEntry(address, { cwd, name: localize('agentHostSessionTaskTerminalName', "Task: {0}", task.label), @@ -107,6 +113,11 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { return provider.remoteAddress ?? LOCAL_AGENT_HOST_ADDRESS; } + private _isSessionRemoteHostAvailable(session: ISession): boolean { + const status = session.remoteConnectionStatus?.get(); + return status === undefined || status.kind === 'connected'; + } + private _getCwd(session: ISession): URI | undefined { const folder = session.workspace.get()?.folders[0]; const cwd = folder?.workingDirectory ?? folder?.root; diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 12a18d43949b6e..59062c64b91cb1 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -150,7 +150,9 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben // This is a little hacky but I don't see any better approach. this._register(autorun(reader => { const session = this._sessionsService.activeSession.read(reader); - if (session?.loading.read(reader) || session?.isArchived.read(reader) || session?.worktreePending?.read(reader)) { + const remoteConnectionStatus = session?.remoteConnectionStatus?.read(reader); + const remoteHostAvailable = remoteConnectionStatus === undefined || remoteConnectionStatus.kind === 'connected'; + if (session?.loading.read(reader) || session?.isArchived.read(reader) || session?.worktreePending?.read(reader) || !remoteHostAvailable) { this._agentHostTerminalService.setDefaultCwd(undefined); return; } @@ -163,15 +165,23 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben const session = this._sessionsService.activeSession.read(reader); const isArchived = session?.isArchived.read(reader); const worktreePending = session?.worktreePending?.read(reader); + const remoteConnectionStatus = session?.remoteConnectionStatus?.read(reader); + const remoteHostAvailable = remoteConnectionStatus === undefined || remoteConnectionStatus.kind === 'connected'; + const remoteHostPermanentlyUnavailable = remoteConnectionStatus?.kind === 'disconnected' || remoteConnectionStatus?.kind === 'incompatible'; + const preserveActiveTerminalState = !remoteHostPermanentlyUnavailable + && !remoteHostAvailable + && this._activeSessionId === session?.sessionId; if (session && !isArchived && this._archivedSessionIds.delete(session.sessionId)) { this._invalidateTerminalOperations(session.sessionId); } - if (session?.loading.read(reader) || isArchived || worktreePending) { - if (session && (isArchived || worktreePending)) { + if (session?.loading.read(reader) || isArchived || worktreePending || !remoteHostAvailable) { + if (session && (isArchived || worktreePending || !remoteHostAvailable)) { this._invalidateTerminalOperations(session.sessionId); } - this._activeKey = undefined; - this._activeSessionId = undefined; + if (!preserveActiveTerminalState) { + this._activeKey = undefined; + this._activeSessionId = undefined; + } return; } this._onActiveSessionChanged(session); @@ -287,6 +297,9 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (!session) { return this._ensureTerminal(cwd, focus, session); } + if (!this._isSessionRemoteHostAvailable(session)) { + return []; + } const generation = this._getTerminalOperationGeneration(session.sessionId); this._beginTerminalOperation(session.sessionId); @@ -350,7 +363,13 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben || this._getTerminalOperationGeneration(session.sessionId) !== generation || this._archivedSessionIds.has(session.sessionId) || session.isArchived.get() - || session.worktreePending?.get() === true; + || session.worktreePending?.get() === true + || !this._isSessionRemoteHostAvailable(session); + } + + private _isSessionRemoteHostAvailable(session: ISession): boolean { + const status = session.remoteConnectionStatus?.get(); + return status === undefined || status.kind === 'connected'; } private _getTerminalOperationGeneration(sessionId: string): number { diff --git a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts index 9c1b735a0597f6..017bf7a762ac5a 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts @@ -19,7 +19,7 @@ import { IAgentHostTerminalCreateOptions, IAgentHostTerminalService } from '../. import { ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_PREFIX } from '../../../../common/agentHostSessionsProvider.js'; -import { IChat, ISession, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IChat, ISession, ISessionFolder, ISessionWorkspace, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IConfigurationResolverService } from '../../../../../workbench/services/configurationResolver/common/configurationResolver.js'; import { IWorkspaceFolderData } from '../../../../../platform/workspace/common/workspace.js'; @@ -27,7 +27,7 @@ import { ITaskEntry, ISessionsTasksService, ISessionTaskWithTarget } from '../.. import { osToTaskTargetOS } from '../../../chat/browser/taskCommand.js'; import { AgentHostSessionTaskRunner } from '../../browser/agentHostSessionTaskRunner.js'; -function makeSession(opts: { providerId: string; cwd?: URI }): ISession { +function makeSession(opts: { providerId: string; cwd?: URI; remoteConnectionStatus?: SessionRemoteConnectionStatus }): ISession { const folder: ISessionFolder | undefined = opts.cwd ? { root: opts.cwd, workingDirectory: opts.cwd, @@ -60,6 +60,7 @@ function makeSession(opts: { providerId: string; cwd?: URI }): ISession { modelId: observableValue('modelId', undefined), mode: observableValue('mode', undefined), loading: observableValue('loading', false), + ...(opts.remoteConnectionStatus ? { remoteConnectionStatus: observableValue('remoteConnectionStatus', opts.remoteConnectionStatus) } : {}), isArchived: observableValue('isArchived', false), isRead: observableValue('isRead', true), lastTurnEnd: observableValue('lastTurnEnd', undefined), @@ -163,6 +164,26 @@ suite('AgentHostSessionTaskRunner', () => { assert.strictEqual(runner.canRun(makeSession({ providerId: 'agenthost-myhost' })), true); }); + test('does not run tasks for an unavailable remote agent host', async () => { + const session = makeSession({ + providerId: 'agenthost-myhost', + cwd: toAgentHostUri(URI.file('/remote/worktree'), 'remote-agenthost-myhost'), + remoteConnectionStatus: { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, + }); + + const handle = await runner.runTask(shellTask(), session); + + assert.deepStrictEqual({ + canRun: runner.canRun(session), + handle, + createdTerminals, + }, { + canRun: false, + handle: undefined, + createdTerminals: [], + }); + }); + test('local agent-host sessions pass through file: cwd', async () => { const cwd = URI.parse('file:///path/to/worktree'); const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd }); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index b2d1cbc17ce85a..a2ca55ad79d697 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -20,7 +20,7 @@ import { ITerminalInstance, ITerminalService } from '../../../../../workbench/co import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { ChatInteractivity, IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionWorkspace, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../../../services/sessions/common/session.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; @@ -59,6 +59,7 @@ type TestActiveSession = IActiveSession & { loading: ReturnType>; isArchived: ReturnType>; worktreePending: ReturnType>; + remoteConnectionStatus?: ReturnType>; }; function makeAgentSession(opts: { @@ -70,6 +71,7 @@ function makeAgentSession(opts: { worktreePending?: boolean; sessionId?: string; providerId?: string; + remoteConnectionStatus?: SessionRemoteConnectionStatus; }): TestActiveSession { const folder = opts.repository || opts.worktree ? { root: opts.repository ?? opts.worktree!, @@ -121,6 +123,7 @@ function makeAgentSession(opts: { mode: chat.mode, loading: observableValue('test.loading', opts.loading ?? false), worktreePending: observableValue('test.worktreePending', opts.worktreePending ?? false), + ...(opts.remoteConnectionStatus ? { remoteConnectionStatus: observableValue('test.remoteConnectionStatus', opts.remoteConnectionStatus) } : {}), isArchived: chat.isArchived, isRead: chat.isRead, lastTurnEnd: chat.lastTurnEnd, @@ -572,6 +575,83 @@ suite('SessionsTerminalContribution', () => { assert.deepStrictEqual(createdTerminals.map(terminal => terminal.cwd.fsPath), [worktreeUri.fsPath]); }); + test('defers remote terminal creation until the host connects', async () => { + const worktreeUri = URI.file('/remote/worktree'); + sessionProviders.set('agenthost-test', { id: 'agenthost-test', remoteAddress: 'remote-test' } as unknown as ISessionsProvider); + const session = makeAgentSession({ + providerId: 'agenthost-test', + providerType: AgentSessionProviders.Background, + worktree: toAgentHostUri(worktreeUri, 'remote-test'), + remoteConnectionStatus: { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, + }); + + activeSessionObs.set(session, undefined); + await tick(); + session.remoteConnectionStatus!.set({ kind: 'incompatible' }, undefined); + await tick(); + session.remoteConnectionStatus!.set({ kind: 'connecting' }, undefined); + await tick(); + const ensured = await contribution.ensureTerminal(worktreeUri, false, session); + + assert.deepStrictEqual({ + created: createdTerminals, + defaultCwd: defaultCwdCalls.at(-1), + ensured, + }, { + created: [], + defaultCwd: undefined, + ensured: [], + }); + + session.remoteConnectionStatus!.set({ kind: 'connected' }, undefined); + await tick(); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.path), + addresses: agentHostTerminalAddresses, + defaultCwd: defaultCwdCalls.at(-1)?.path, + }, { + created: [worktreeUri.path], + addresses: ['remote-test'], + defaultCwd: worktreeUri.path, + }); + }); + + test('keeps an existing remote terminal during a transient reconnect', async () => { + const worktreeUri = URI.file('/remote/worktree'); + sessionProviders.set('agenthost-test', { id: 'agenthost-test', remoteAddress: 'remote-test' } as unknown as ISessionsProvider); + const session = makeAgentSession({ + providerId: 'agenthost-test', + providerType: AgentSessionProviders.Background, + worktree: toAgentHostUri(worktreeUri, 'remote-test'), + remoteConnectionStatus: { kind: 'connected' }, + }); + + activeSessionObs.set(session, undefined); + await tick(); + session.remoteConnectionStatus!.set({ kind: 'reconnecting' }, undefined); + await tick(); + const duringReconnect = { + created: createdTerminals.map(terminal => terminal.cwd.path), + defaultCwd: defaultCwdCalls.at(-1), + }; + session.remoteConnectionStatus!.set({ kind: 'connected' }, undefined); + await tick(); + + assert.deepStrictEqual({ + duringReconnect, + created: createdTerminals.map(terminal => terminal.cwd.path), + defaultCwd: defaultCwdCalls.at(-1)?.path, + }, { + duringReconnect: { + created: [worktreeUri.path], + defaultCwd: undefined, + }, + created: [worktreeUri.path], + defaultCwd: worktreeUri.path, + }); + }); + test('disposes terminal creation that becomes stale while the worktree is pending', async () => { const worktreeUri = URI.file('/worktree'); const session = makeAgentSession({ diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 8fad58ef39b396..7cf101bcd319ac 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -243,6 +243,7 @@ export class VisibleSession extends Disposable implements IActiveSession { get isQuickChat() { return this._session.isQuickChat; } get isAutomation() { return this._session.isAutomation; } get isExternal() { return this._session.isExternal; } + get remoteConnectionStatus() { return this._session.remoteConnectionStatus; } get createdBySession() { return this._session.createdBySession; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } @@ -294,6 +295,7 @@ class ResourceOverrideSession implements ISession { get isQuickChat() { return this._session.isQuickChat; } get isAutomation() { return this._session.isAutomation; } get isExternal() { return this._session.isExternal; } + get remoteConnectionStatus() { return this._session.remoteConnectionStatus; } get createdBySession() { return this._session.createdBySession; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index e18e23c2dfa465..f5815b581f9c50 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -81,6 +81,22 @@ export const enum SessionStatus { Error = 4, } +/** + * Connection state of the remote agent host backing a session. + */ +export type SessionRemoteConnectionStatus = + | { readonly kind: 'connected' } + | { readonly kind: 'connecting' } + | { readonly kind: 'reconnecting' } + | { readonly kind: 'disconnected'; readonly reason: SessionRemoteConnectionFailureReason } + | { readonly kind: 'incompatible' }; + +/** Machine-readable reasons a remote session's host is disconnected. */ +export const enum SessionRemoteConnectionFailureReason { + Unknown = 'unknown', + HostNotRunning = 'hostNotRunning', +} + /** Whether a session still has active work, including work blocked on user input. */ export function isActiveSessionStatus(status: SessionStatus): boolean { return status === SessionStatus.InProgress || status === SessionStatus.NeedsInput; @@ -753,6 +769,8 @@ export interface ISession { readonly isAutomation?: IObservable; /** Whether this session was discovered in an application other than the current host. Absent means `false`. */ readonly isExternal?: IObservable; + /** Connection state of the backing remote host. Absent when the session has no remote host. */ + readonly remoteConnectionStatus?: IObservable; /** Session turn that created this session, when it was created by another agent session. */ readonly createdBySession?: IObservable; diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index c00288c359594b..00e80673c05fe1 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -12,7 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { VisibleSession, VisibleSessions } from '../../browser/visibleSessions.js'; -import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../common/session.js'; const stubChat: IChat = { resource: URI.parse('test:///chat'), @@ -88,7 +88,8 @@ suite('VisibleSessions', () => { const hasGitRepository = observableValue('hasGitRepository', false); const completedStateIcon = observableValue('completedStateIcon', Codicon.gitMerge); const isExternal = observableValue('isExternal', true); - const session = { ...stubSession('A'), completedStateIcon, hasGitRepository, isExternal }; + const remoteConnectionStatus = constObservable({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }); + const session = { ...stubSession('A'), completedStateIcon, hasGitRepository, isExternal, remoteConnectionStatus }; const model = createModel(); model.setActive(session); const visible = model.activeSession.get(); @@ -101,6 +102,8 @@ suite('VisibleSessions', () => { resourceOverrideCompletedStateIcon: resourceOverride.completedStateIcon === completedStateIcon, visibleExternal: visible?.isExternal === isExternal, resourceOverrideExternal: resourceOverride.isExternal === isExternal, + visibleRemoteConnectionStatus: visible?.remoteConnectionStatus === remoteConnectionStatus, + resourceOverrideRemoteConnectionStatus: resourceOverride.remoteConnectionStatus === remoteConnectionStatus, }, { visible: true, resourceOverride: true, @@ -108,6 +111,8 @@ suite('VisibleSessions', () => { resourceOverrideCompletedStateIcon: true, visibleExternal: true, resourceOverrideExternal: true, + visibleRemoteConnectionStatus: true, + resourceOverrideRemoteConnectionStatus: true, }); }); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 04d4661d597d15..dba0a2e6788b68 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -5,26 +5,31 @@ import assert from 'assert'; import { mainWindow } from '../../../base/browser/window.js'; -import { DeferredPromise } from '../../../base/common/async.js'; -import { Event } from '../../../base/common/event.js'; +import { DeferredPromise, timeout } from '../../../base/common/async.js'; +import { errorHandler, setUnexpectedErrorHandler } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../base/test/common/timeTravelScheduler.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; import { AbstractChatView, ChatViewKind } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; +import { type IAgentHostConnectProgress, IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; -import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionCapabilities, SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionCapabilities, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvider } from '../../services/sessions/common/sessionsProvider.js'; class TestChatView extends AbstractChatView { private readonly _focusTarget = mainWindow.document.createElement('button'); + override readonly hasVisibleTranscriptContent = observableValue(this, false); layoutCount = 0; constructor(readonly kind: ChatViewKind) { @@ -64,22 +69,33 @@ class TestChatViewFactory extends mock() { } } -function createChat(id: string, status: SessionStatus = SessionStatus.Completed, parentChat?: URI): IChat { - const resource = URI.parse(`test-chat://${id}`); - return new class extends mock() { - override readonly resource = resource; - override readonly origin = parentChat ? { kind: ChatOriginKind.Tool, parentChat } : undefined; - override readonly title: IObservable = constObservable(id); - override readonly status: IObservable = constObservable(status); - override readonly isRead: IObservable = constObservable(true); - override readonly interactivity: IObservable = constObservable(ChatInteractivity.Full); - }(); +class TestChat extends mock() { + override readonly resource: URI; + override readonly origin: IChat['origin']; + override readonly title: IObservable; + override readonly status: ISettableObservable; + override readonly isRead: IObservable = constObservable(true); + override readonly interactivity: ISettableObservable; + + constructor(id: string, status = SessionStatus.Completed, parentChat?: URI) { + super(); + this.resource = URI.parse(`test-chat://${id}`); + this.origin = parentChat ? { kind: ChatOriginKind.Tool, parentChat } : undefined; + this.title = constObservable(id); + this.status = observableValue(this, status); + this.interactivity = observableValue(this, ChatInteractivity.Full); + } +} + +function createChat(id: string, status: SessionStatus = SessionStatus.Completed, parentChat?: URI): TestChat { + return new TestChat(id, status, parentChat); } class TestActiveSession extends mock() { override readonly sessionId = 'session'; override readonly resource = URI.parse('test-session://session'); - override readonly providerId = 'test'; + override readonly providerId: string; + override readonly remoteConnectionStatus: ISettableObservable | undefined; readonly allChats: ISettableObservable; override readonly visibleChatTabs: ISettableObservable; override readonly activeChat: ISettableObservable; @@ -91,11 +107,13 @@ class TestActiveSession extends mock() { override readonly capabilities: IObservable = constObservable({ supportsMultipleChats: true }); override readonly isCreated: IObservable; override readonly isNewSessionRequestInProgress = observableValue(this, false); - override readonly isArchived: IObservable = constObservable(false); + override readonly isArchived = observableValue(this, false); override readonly loading: IObservable = constObservable(false); - constructor(chats: readonly IChat[], visibleChats: readonly IChat[] = chats, isCreated = true) { + constructor(chats: readonly IChat[], visibleChats: readonly IChat[] = chats, isCreated = true, providerId = 'test', remoteConnectionStatus?: SessionRemoteConnectionStatus) { super(); + this.providerId = providerId; + this.remoteConnectionStatus = remoteConnectionStatus && observableValue(this, remoteConnectionStatus); const mainChat = chats[0]; if (!mainChat) { throw new Error('A test session requires a main chat'); @@ -141,9 +159,42 @@ class TestSessionsService extends mock() { } +class TestSessionsProvidersService extends mock() { + override readonly onDidChangeProviders = Event.None; + provider: ISessionsProvider | undefined; + + override getProvider(_providerId: string): T | undefined { + return this.provider as T | undefined; + } +} + +class TestAgentHostProvider extends mock() { + override readonly id = 'agenthost-test'; + override readonly label = 'WSL: Ubuntu'; + override readonly remoteAddress = 'wsl:Ubuntu'; + private readonly _onDidReportConnectProgress = new Emitter(); + override readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event; + connectCalls = 0; + connectGate: Promise | undefined; + + override async connect(): Promise { + this.connectCalls++; + await this.connectGate; + } + + reportConnectProgress(connectionKey: string, message: string): void { + this._onDidReportConnectProgress.fire({ connectionKey, message }); + } + + get hasProgressListener(): boolean { + return this._onDidReportConnectProgress.hasListeners(); + } +} + interface IChatGroupsHarness { readonly instantiationService: TestInstantiationService; readonly sessionsService: TestSessionsService; + readonly sessionsProvidersService: TestSessionsProvidersService; readonly chatViewFactory: TestChatViewFactory; readonly view: ChatGroupsView; } @@ -153,22 +204,43 @@ function createHarness(disposables: Pick, tabsReplaceHea const instantiationService = workbenchInstantiationService(undefined, store); const sessionsService = new TestSessionsService(); const chatViewFactory = new TestChatViewFactory(); + const sessionsProvidersService = new TestSessionsProvidersService(); instantiationService.stub(IChatViewFactory, chatViewFactory); instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsManagementService, new class extends mock() { override readonly onDidChangeSessions = Event.None; }()); instantiationService.stub(ISessionsPartService, new class extends mock() { }); - instantiationService.stub(ISessionsProvidersService, new class extends mock() { - override readonly onDidChangeProviders = Event.None; - override getProvider() { return undefined; } - }()); + instantiationService.stub(ISessionsProvidersService, sessionsProvidersService); const view = store.add(instantiationService.createInstance(ChatGroupsView)); view.setSingleGroupTabsReplaceHeader(tabsReplaceHeader); mainWindow.document.body.appendChild(view.element); store.add(toDisposable(() => view.element.remove())); - return { instantiationService, sessionsService, chatViewFactory, view }; + return { instantiationService, sessionsService, sessionsProvidersService, chatViewFactory, view }; +} + +function readBanner(view: ChatGroupsView): { readonly visible: boolean; readonly message: string | undefined; readonly action: string | undefined } { + const banner = view.element.querySelector('.session-readonly-banner'); + return { + visible: !banner?.classList.contains('hidden'), + message: banner?.querySelector('.session-readonly-banner-text')?.textContent ?? undefined, + action: banner?.querySelector('.session-readonly-banner-action-link')?.textContent ?? undefined, + }; +} + +function readRemoteHostUnavailableState(view: ChatGroupsView): { readonly visible: boolean; readonly title: string | undefined; readonly description: string | undefined; readonly progress: string | undefined; readonly action: string | undefined } { + const state = view.element.querySelector('.remote-host-unavailable-empty-state'); + // The action container is always present and hidden when there is no action, + // so report the label only while it is actually offered. + const action = state?.querySelector('.remote-host-unavailable-empty-state-action'); + return { + visible: !state?.classList.contains('hidden'), + title: state?.querySelector('.remote-host-unavailable-empty-state-title')?.textContent ?? undefined, + description: state?.querySelector('.remote-host-unavailable-empty-state-description')?.textContent ?? undefined, + progress: state?.querySelector('.remote-host-unavailable-empty-state-progress:not(.hidden)')?.textContent ?? undefined, + action: action && !action.classList.contains('hidden') ? action.textContent ?? undefined : undefined, + }; } suite('Sessions - ChatGroupsView', () => { @@ -560,4 +632,275 @@ suite('Sessions - ChatGroupsView', () => { assert.strictEqual(view.element.querySelector('.session-chat-tabs-actions')?.classList.contains('hidden'), true); }); + test('hides the remote host banner when connected or when no remote host backs the session', () => { + const { view } = createHarness(disposables); + view.setSession(new TestActiveSession([createChat('main')]), options); + const withoutRemoteHost = readBanner(view); + + view.setSession(new TestActiveSession([createChat('connected')], undefined, true, 'agenthost-test', { kind: 'connected' }), options); + + assert.deepStrictEqual({ withoutRemoteHost, connected: readBanner(view) }, { + withoutRemoteHost: { visible: false, message: 'This chat is read-only', action: undefined }, + connected: { visible: false, message: 'This chat is read-only', action: undefined }, + }); + }); + + test('presents host-not-running and unknown remote host disconnections distinctly', () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const remoteConnectionStatus = session.remoteConnectionStatus; + assert.ok(remoteConnectionStatus); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + const hostNotRunning = readBanner(view); + + view.element.querySelector('.session-readonly-banner-action-link')?.click(); + remoteConnectionStatus.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }, undefined); + + assert.deepStrictEqual({ + hostNotRunning, + unknownDisconnected: readBanner(view), + connectCalls: provider.connectCalls, + }, { + hostNotRunning: { visible: true, message: 'WSL: Ubuntu is not running.', action: 'Start WSL: Ubuntu' }, + unknownDisconnected: { visible: true, message: 'Cannot reach WSL: Ubuntu.', action: undefined }, + connectCalls: 1, + }); + }); + + test('uses a centered recovery state for an unloaded remote session and a banner for a chat transcript', () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + // Cached metadata produces an existing chat tab, but no chat model or + // rendered transcript until the remote host can be reached. + const unloaded = new TestActiveSession([createChat('cached')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const status = unloaded.remoteConnectionStatus; + assert.ok(status); + view.setSession(unloaded, options); + const hostNotRunning = { + state: readRemoteHostUnavailableState(view), + banner: readBanner(view).visible, + }; + + view.element.querySelector('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + status.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }, undefined); + const unknownDisconnected = { + state: readRemoteHostUnavailableState(view), + banner: readBanner(view).visible, + }; + + const existing = new TestActiveSession([createChat('existing')], undefined, true, provider.id, { kind: 'connected' }); + const existingStatus = existing.remoteConnectionStatus; + assert.ok(existingStatus); + view.setSession(existing, options); + // Model a transcript already rendered before the transport drops. The + // second session creates its own view, so target the current one rather + // than the view left over from the unloaded session above. + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + existingStatus.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, undefined); + const chatWithContent = { + state: readRemoteHostUnavailableState(view).visible, + banner: readBanner(view), + }; + + assert.deepStrictEqual({ + hostNotRunning, + unknownDisconnected, + chatWithContent, + connectCalls: provider.connectCalls, + }, { + hostNotRunning: { + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + }, + banner: false, + }, + unknownDisconnected: { + state: { + visible: true, + title: 'Cannot Connect to WSL: Ubuntu', + description: 'Cannot reach WSL: Ubuntu.', + progress: undefined, + action: undefined, + }, + banner: false, + }, + chatWithContent: { + state: false, + banner: { + visible: true, + message: 'WSL: Ubuntu is not running.', + action: 'Start WSL: Ubuntu', + }, + }, + connectCalls: 1, + }); + }); + + test('shows only its host connection progress and disposes listeners with its session and view', async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const firstConnect = new DeferredPromise(); + provider.connectGate = firstConnect.p; + sessionsProvidersService.provider = provider; + const firstSession = new TestActiveSession([createChat('first')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + view.setSession(firstSession, options); + + view.element.querySelector('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + provider.reportConnectProgress('wsl:Debian', 'Downloading server (24%)'); + const otherHostProgress = readRemoteHostUnavailableState(view); + provider.reportConnectProgress('wsl:Ubuntu', 'Downloading server (80%)'); + const ownHostProgress = readRemoteHostUnavailableState(view); + firstConnect.complete(); + await Promise.resolve(); + await Promise.resolve(); + const completedAttempt = readRemoteHostUnavailableState(view); + const listenerAfterAttempt = provider.hasProgressListener; + + const secondConnect = new DeferredPromise(); + provider.connectGate = secondConnect.p; + view.element.querySelector('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + const secondSession = new TestActiveSession([createChat('second')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + view.setSession(secondSession, options); + const listenerAfterSessionChange = provider.hasProgressListener; + view.element.querySelector('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + const listenerBeforeDispose = provider.hasProgressListener; + view.dispose(); + const listenerAfterDispose = provider.hasProgressListener; + secondConnect.complete(); + await Promise.resolve(); + + assert.deepStrictEqual({ + otherHostProgress, + ownHostProgress, + completedAttempt, + listenerAfterAttempt, + listenerAfterSessionChange, + listenerBeforeDispose, + listenerAfterDispose, + }, { + otherHostProgress: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + }, + ownHostProgress: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Downloading server (80%)', + action: undefined, + }, + completedAttempt: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + }, + listenerAfterAttempt: false, + listenerAfterSessionChange: false, + listenerBeforeDispose: true, + listenerAfterDispose: false, + }); + }); + + test('preserves the archived-session banner when its remote host is unavailable', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const chat = createChat('main'); + chat.interactivity.set(ChatInteractivity.ReadOnly, undefined); + const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + session.isArchived.set(true, undefined); + + view.setSession(session, options); + + assert.deepStrictEqual(readBanner(view), { + visible: true, + message: 'Archived sessions are read-only.', + action: 'Unarchive', + }); + }); + + test('does not show a reconnecting banner when the connection settles before its delay', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'connected' }); + const remoteConnectionStatus = session.remoteConnectionStatus; + assert.ok(remoteConnectionStatus); + view.setSession(session, options); + + remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); + remoteConnectionStatus.set({ kind: 'connected' }, undefined); + await timeout(1_000); + + assert.deepStrictEqual(readBanner(view), { visible: false, message: 'This chat is read-only', action: undefined }); + }); + }); + + test('shows the reconnecting banner after its delay despite unrelated observable updates', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const chat = createChat('main'); + const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'reconnecting' }); + view.setSession(session, options); + + await timeout(500); + chat.status.set(SessionStatus.Error, undefined); + await timeout(500); + + assert.deepStrictEqual(readBanner(view), { + visible: true, + message: 'Reconnecting to WSL: Ubuntu...', + action: undefined, + }); + }); + }); + + test('shows the reconnecting banner after a failed connect attempt', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const failedConnect = new DeferredPromise(); + provider.connectGate = failedConnect.p; + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const remoteConnectionStatus = session.remoteConnectionStatus!; + view.setSession(session, options); + + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(() => { }); + try { + view.element.querySelector('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + failedConnect.error(new Error('Expected connect failure')); + await Promise.resolve(); + await Promise.resolve(); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + + remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); + await timeout(1_000); + + assert.deepStrictEqual({ connectCalls: provider.connectCalls, banner: readBanner(view) }, { + connectCalls: 1, + banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu...', action: undefined }, + }); + }); + }); + }); diff --git a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts new file mode 100644 index 00000000000000..1431eb90991fd4 --- /dev/null +++ b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from '../../browser/parts/remoteHostUnavailableEmptyState.js'; + +export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/' }, { + HostNotRunning: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + action: { label: 'Start WSL: Ubuntu', run: () => { } }, + }), + }), + + HostDisconnected: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Cannot Connect to WSL: Ubuntu', + description: 'Cannot reach WSL: Ubuntu.', + }), + }), + + Connecting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Downloading server (80%)', + }), + }), +}); + +function renderUnavailableState({ container, disposableStore }: ComponentFixtureContext, content: IRemoteHostUnavailableEmptyStateContent): void { + container.style.position = 'relative'; + container.style.width = 'var(--session-view-centered-content-max-width)'; + container.style.height = 'calc(var(--vscode-spacing-size400) * 6)'; + container.style.backgroundColor = 'var(--vscode-editorWidget-background)'; + + const state = disposableStore.add(new RemoteHostUnavailableEmptyState()); + state.setContent(content); + container.appendChild(state.domNode); +} diff --git a/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts b/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts index 5599a678ba650a..426de4c05c2a7c 100644 --- a/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts +++ b/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../base/common/codicons.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ISessionReadOnlyBannerContent, SessionReadOnlyBanner } from '../../browser/parts/sessionReadOnlyBanner.js'; @@ -21,6 +22,31 @@ export default defineThemedFixtureGroup({ path: 'sessions/readOnlyBanner/' }, { action: { label: 'Restore', run: () => console.log('Restore') }, }), }), + + Reconnecting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanner(context, { + icon: Codicon.sync, + message: 'Reconnecting to WSL: Ubuntu...', + }), + }), + + HostNotRunning: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanner(context, { + icon: Codicon.debugDisconnect, + message: 'WSL: Ubuntu is not running.', + action: { label: 'Start WSL: Ubuntu', run: () => console.log('Start') }, + }), + }), + + HostDisconnected: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanner(context, { + icon: Codicon.debugDisconnect, + message: 'Cannot reach WSL: Ubuntu.', + }), + }), }); function renderBanner({ container, disposableStore }: ComponentFixtureContext, content: ISessionReadOnlyBannerContent): void { From 39db97d65a78e09a748cb5ad486c933400414105 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 2 Sep 2026 12:55:08 -0700 Subject: [PATCH 2/6] Agent Host: auto-start a stopped remote host from the recovery surface Adds an opt-in, kind-scoped policy that starts a stopped remote agent host instead of waiting for the user to press Start. Providers expose it through `IAgentHostAutoConnect` (label, observable value, setter) and choose how it is backed; WSL backs it with `chat.agentHost.wsl.autoStart`. The recovery screen and the inline banner both render the checkbox and live connect progress. The Start action is never rendered while an automatic start is pending: the content derivation itself returns the connecting presentation, so this holds structurally rather than depending on autorun ordering. Two ordering bugs surfaced while building this. A connect that resolved without reaching the host cleared the in-flight attempt and re-opened the automatic gate, spinning forever behind a permanent "Waiting for agent host connection...". The gate is now latched per outage and released once the host is reachable, so a mid-session drop still gets its own attempt while an ineffective connect does not retrigger. The service fired its connection-change notification from inside a failing dial, before clearing the in-flight marker. A consumer dialing from that notification joined the dial that had just failed, so nothing reconnected and its `waitForConnection` never settled. The marker is now cleared before notifying; `_connectTo` clears by identity, so a dial started from the notification survives. Both are covered by regression tests that reproduce the original hangs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostServiceImpl.ts | 7 + .../agentHost/common/wslRemoteAgentHost.ts | 3 + .../remoteAgentHostService.test.ts | 38 +++ .../media/remoteHostUnavailableEmptyState.css | 20 +- .../parts/remoteHostUnavailableEmptyState.ts | 65 ++++- .../browser/parts/sessionRemoteConnection.ts | 115 ++++++++- .../common/agentHostSessionsProvider.ts | 21 ++ .../entryDrivenProviderContribution.ts | 4 +- .../browser/remoteAgentHost.contribution.ts | 8 + .../remoteAgentHostSessionsProvider.ts | 6 +- .../browser/wslAgentHost.contribution.ts | 19 +- .../test/browser/chatGroupsView.test.ts | 236 +++++++++++++++++- ...remoteHostUnavailableEmptyState.fixture.ts | 11 + 13 files changed, 524 insertions(+), 29 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index 8eee6ef1f72dfd..e87397048e4f76 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -587,6 +587,13 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo reconnectTransfersTransportOwnership: false, }); this._rejectPendingConnectionWait(address, err); + // Clear the in-flight marker before notifying. A consumer may dial again + // from this notification — an automatic start does — and `reconnect` + // joins a pending dial rather than racing it. Leaving the marker set + // would join *this* dial, which has already failed, so the new request + // would never connect and its `waitForConnection` would never settle. + // `_connectTo` clears by identity, so a fresh dial started here survives. + this._pendingConnects.delete(address); // Nothing else reports this failure: no entry was created, so consumers // only learn the address became unavailable from this notification. this._onDidChangeConnections.fire(); diff --git a/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts b/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts index 27a057bf483f57..abcda4cd5456b0 100644 --- a/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts @@ -22,6 +22,9 @@ export const WSL_INSTALL_DOCS_URL = 'https://aka.ms/vscode-remote/wsl/install-ws */ export const WSL_ADDRESS_PREFIX = 'wsl:'; +/** Controls whether opening a chat automatically starts its stopped WSL host. */ +export const WslAutoStartSettingId = 'chat.agentHost.wsl.autoStart'; + /** * A WSL distribution discovered via `wsl --list`. Only WSL 2 distros are * surfaced — WSL 1 lacks the kernel features needed to host the agent. diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 6f639b1eb3c8e0..01d198142fa48e 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -878,6 +878,44 @@ suite('RemoteAgentHostService', () => { }); }); + test('starts a fresh dial when a reconnect is requested from the failure notification', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const address = getEntryAddress(entry); + const client = new MockProtocolClient(address); + + // An automatic start reacts to this notification synchronously, while + // the failing dial is still on the stack. If its in-flight marker were + // still set, `reconnect` would join that dead dial instead of opening a + // new one, so the host would never come up and the wait never settle. + const listener = disposables.add(service.onDidChangeConnections(() => { + if (service.connections.find(connection => connection.address === address)?.status.kind !== 'disconnected') { + return; + } + listener.dispose(); + factory.stage(entry, client); + service.reconnect(address, true); + })); + + factory.stageFailure(entry, new NonReconnectableTransportError(`WSL distro 'Ubuntu' is not running.`, AgentHostTransportFailureReason.HostNotRunning)); + // Waiting only once the fresh dial exists: waiters registered earlier are + // rejected by the failure itself, which is not what this pins. + await waitForFactoryConnection(factory, 1); + client.connectDeferred.complete(); + const info = await service.waitForConnection(address); + + assert.deepStrictEqual({ + createdConnectionCount: factory.createdConnectionCount, + status: info.status, + }, { + createdConnectionCount: 1, + status: RemoteAgentHostConnectionStatus.connected, + }); + }); + test('surfaces a stopped WSL distro as a host-not-running disconnect and clears the reason once it reconnects', async () => { const factory = createFactory(RemoteAgentHostEntryType.WSL); const entry: IRemoteAgentHostEntry = { diff --git a/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css index a60ba4307fd6f3..719e7ecc78bfdc 100644 --- a/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css +++ b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css @@ -19,7 +19,8 @@ .remote-host-unavailable-empty-state.hidden, .remote-host-unavailable-empty-state-progress.hidden, -.remote-host-unavailable-empty-state-action.hidden { +.remote-host-unavailable-empty-state-action.hidden, +.remote-host-unavailable-empty-state-auto-connect.hidden { display: none; } @@ -56,6 +57,23 @@ margin-top: var(--vscode-spacing-size80); } +.remote-host-unavailable-empty-state-auto-connect { + margin-top: var(--vscode-spacing-size80); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.remote-host-unavailable-empty-state-auto-connect-row { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + cursor: pointer; +} + +.remote-host-unavailable-empty-state-auto-connect-label { + cursor: pointer; +} + .chat-group-view-content.remote-host-unavailable > .chat-view { visibility: hidden; pointer-events: none; diff --git a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts index 036fe1202a0997..2652e9d0ab4458 100644 --- a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts +++ b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts @@ -7,9 +7,11 @@ import './media/remoteHostUnavailableEmptyState.css'; import * as dom from '../../../base/browser/dom.js'; import { renderIcon } from '../../../base/browser/ui/iconLabel/iconLabels.js'; import { Button } from '../../../base/browser/ui/button/button.js'; +import { Checkbox } from '../../../base/browser/ui/toggle/toggle.js'; +import { Gesture, EventType as TouchEventType } from '../../../base/browser/touch.js'; import { Codicon } from '../../../base/common/codicons.js'; -import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { defaultButtonStyles } from '../../../platform/theme/browser/defaultStyles.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { defaultButtonStyles, defaultCheckboxStyles } from '../../../platform/theme/browser/defaultStyles.js'; export interface IRemoteHostUnavailableEmptyStateContent { readonly title: string; @@ -19,6 +21,12 @@ export interface IRemoteHostUnavailableEmptyStateContent { readonly label: string; readonly run: () => void; }; + /** Optional kind-scoped auto-start policy, rendered beneath the action. */ + readonly autoConnect?: { + readonly label: string; + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; + }; } /** @@ -33,6 +41,11 @@ export class RemoteHostUnavailableEmptyState extends Disposable { private readonly _actionContainer: HTMLElement; private readonly _action: Button; private readonly _actionListener = this._register(new MutableDisposable()); + private readonly _autoConnectContainer: HTMLElement; + private readonly _autoConnectRow: HTMLElement; + private readonly _autoConnect: Checkbox; + private readonly _autoConnectLabel: HTMLElement; + private readonly _autoConnectListener = this._register(new MutableDisposable()); constructor() { super(); @@ -51,6 +64,13 @@ export class RemoteHostUnavailableEmptyState extends Disposable { this._actionContainer = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-action.hidden')); // Primary styling: recovering the host is the one thing to do here. this._action = this._register(new Button(this._actionContainer, { ...defaultButtonStyles, title: true })); + this._autoConnectContainer = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-auto-connect.hidden')); + this._autoConnectRow = dom.append(this._autoConnectContainer, dom.$('.remote-host-unavailable-empty-state-auto-connect-row')); + this._autoConnect = this._register(new Checkbox('', false, { ...defaultCheckboxStyles, size: 14 })); + dom.append(this._autoConnectRow, this._autoConnect.domNode); + this._autoConnectLabel = dom.append(this._autoConnectRow, dom.$('span.remote-host-unavailable-empty-state-auto-connect-label')); + this._autoConnectLabel.setAttribute('aria-hidden', 'true'); + this._register(Gesture.addTarget(this._autoConnectRow)); } get visible(): boolean { @@ -59,12 +79,17 @@ export class RemoteHostUnavailableEmptyState extends Disposable { setContent(content: IRemoteHostUnavailableEmptyStateContent | undefined): void { this._actionListener.clear(); + this._autoConnectListener.clear(); this.domNode.classList.toggle('hidden', !content); if (!content) { this._progress.textContent = ''; this._progress.classList.add('hidden'); this._actionContainer.classList.add('hidden'); this._action.label = ''; + this._autoConnectContainer.classList.add('hidden'); + this._autoConnectLabel.textContent = ''; + this._autoConnect.checked = false; + this._autoConnect.setTitle(''); return; } @@ -76,13 +101,41 @@ export class RemoteHostUnavailableEmptyState extends Disposable { if (!content.action) { this._actionContainer.classList.add('hidden'); this._action.label = ''; + } else { + this._actionContainer.classList.remove('hidden'); + this._action.label = content.action.label; + this._action.setAriaLabel(content.action.label); + this._actionListener.value = this._action.onDidClick(() => content.action?.run()); + } + + const autoConnect = content.autoConnect; + this._autoConnectContainer.classList.toggle('hidden', !autoConnect); + this._autoConnectLabel.textContent = autoConnect?.label ?? ''; + this._autoConnect.checked = autoConnect?.checked ?? false; + this._autoConnect.setTitle(autoConnect?.label ?? ''); + if (!autoConnect) { return; } - this._actionContainer.classList.remove('hidden'); - this._action.label = content.action.label; - this._action.setAriaLabel(content.action.label); - this._actionListener.value = this._action.onDidClick(() => content.action?.run()); + const listeners = new DisposableStore(); + listeners.add(this._autoConnect.onChange(() => autoConnect.onChange(this._autoConnect.checked))); + listeners.add(dom.addDisposableListener(this._autoConnectRow, dom.EventType.CLICK, e => { + if (!this._autoConnect.enabled) { + return; + } + dom.EventHelper.stop(e, true); + this._autoConnect.checked = !this._autoConnect.checked; + autoConnect.onChange(this._autoConnect.checked); + })); + listeners.add(dom.addDisposableListener(this._autoConnectRow, TouchEventType.Tap, e => { + if (!this._autoConnect.enabled) { + return; + } + dom.EventHelper.stop(e, true); + this._autoConnect.checked = !this._autoConnect.checked; + autoConnect.onChange(this._autoConnect.checked); + })); + this._autoConnectListener.value = listeners; } focus(): void { diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index f85c9068a3fe10..63f180c5b823c2 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -7,8 +7,9 @@ import { TimeoutTimer } from '../../../base/common/async.js'; import { Codicon } from '../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue } from '../../../base/common/observable.js'; +import { autorun, derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue } from '../../../base/common/observable.js'; import { localize } from '../../../nls.js'; +import { ILogService } from '../../../platform/log/common/log.js'; import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; import { SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; @@ -18,8 +19,18 @@ import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js'; const RECONNECTING_BANNER_DELAY = 1_000; +function isSameRemoteConnectionStatus(a: SessionRemoteConnectionStatus | undefined, b: SessionRemoteConnectionStatus | undefined): boolean { + if (!a || !b) { + return a === b; + } + if (a.kind === 'disconnected' && b.kind === 'disconnected') { + return a.reason === b.reason; + } + return a.kind === b.kind; +} + type ConnectAttempt = - | { readonly kind: 'active'; readonly session: IActiveSession; readonly message: string | undefined } + | { readonly kind: 'active'; readonly session: IActiveSession; readonly message: string | undefined; readonly statusBefore: SessionRemoteConnectionStatus | undefined } | { readonly kind: 'failed'; readonly session: IActiveSession; readonly statusBefore: SessionRemoteConnectionStatus | undefined }; /** @@ -31,6 +42,32 @@ export class SessionRemoteConnection extends Disposable { private readonly _attempt = observableValue(this, undefined); private readonly _progressListener = this._register(new MutableDisposable()); private readonly _deadlineSignal = observableSignal(this); + /** + * The session an automatic start has already been triggered for, cleared + * once the host is reachable again so each outage gets its own attempt. + * Latched separately from {@link _attempt} because a completed attempt + * clears that back to `undefined`; keying the gate on it would let a connect + * that resolves without reaching the host retrigger forever. + */ + private readonly _autoConnected = observableValue(this, undefined); + /** + * Whether the host should be started without waiting for a click. Gated on a + * stopped host the provider can start, and on not having tried yet — one + * automatic try per outage, after which the user gets the button. + */ + private readonly _autoConnectPending = derived(this, reader => { + const session = this._session.read(reader); + const provider = session && this._sessionsProvidersService.getProvider(session.providerId); + const status = this._getEffectiveStatus(reader); + if (!session || !provider || !isAgentHostProvider(provider) || !provider.connect || !provider.autoConnect) { + return false; + } + return provider.autoConnect.enabled.read(reader) + && status?.kind === 'disconnected' + && status.reason === SessionRemoteConnectionFailureReason.HostNotRunning + && this._autoConnected.read(reader) !== session + && this._attempt.read(reader) === undefined; + }); private readonly _reconnectingSince = derivedObservableWithCache<{ readonly session: IActiveSession; readonly since: number } | undefined>(this, (reader, last) => { const session = this._session.read(reader); @@ -66,24 +103,42 @@ export class SessionRemoteConnection extends Disposable { constructor( @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @ILogService private readonly _logService: ILogService, ) { super(); + this._register(autorun(reader => { + // A host that came back releases the latch, so a later outage gets its + // own automatic attempt instead of the session being limited to one. + if (this._getEffectiveStatus(reader)?.kind === 'connected' && this._autoConnected.read(reader) !== undefined) { + this._autoConnected.set(undefined, undefined); + } + if (!this._autoConnectPending.read(reader)) { + return; + } + const session = this._session.read(reader); + this._logService.info(`[SessionRemoteConnection] auto-connect triggered for ${session?.providerId}`); + this._autoConnected.set(session, undefined); + this.connect(); + })); } setSession(session: IActiveSession | undefined): void { this._progressListener.clear(); this._attempt.set(undefined, undefined); + this._autoConnected.set(undefined, undefined); this._session.set(session, undefined); } connect(): void { const session = this._session.get(); if (!session) { + this._logService.info('[SessionRemoteConnection] connect: no session'); return; } const attempt = this._attempt.get(); if (attempt?.kind === 'active' && attempt.session === session) { + this._logService.info('[SessionRemoteConnection] connect: attempt already active'); return; } if (attempt?.kind === 'failed') { @@ -92,11 +147,13 @@ export class SessionRemoteConnection extends Disposable { const provider = this._sessionsProvidersService.getProvider(session.providerId); if (!provider || !isAgentHostProvider(provider) || !provider.connect) { + this._logService.info(`[SessionRemoteConnection] connect: ${session.providerId} cannot connect on demand`); return; } const statusBefore = session.remoteConnectionStatus?.get(); - this._attempt.set({ kind: 'active', session, message: undefined }, undefined); + this._logService.info(`[SessionRemoteConnection] connect: starting ${provider.remoteAddress ?? provider.id}, statusBefore=${statusBefore?.kind}`); + this._attempt.set({ kind: 'active', session, message: undefined, statusBefore }, undefined); const remoteAddress = provider.remoteAddress; if (remoteAddress && provider.onDidReportConnectProgress) { this._progressListener.value = provider.onDidReportConnectProgress(progress => { @@ -116,8 +173,10 @@ export class SessionRemoteConnection extends Disposable { private async _runConnect(session: IActiveSession, statusBefore: SessionRemoteConnectionStatus | undefined, connect: () => Promise): Promise { try { await connect(); + this._logService.info(`[SessionRemoteConnection] connect resolved, status=${session.remoteConnectionStatus?.get()?.kind}`); this._finishAttempt(session); } catch (error) { + this._logService.info(`[SessionRemoteConnection] connect rejected: ${error}`); this._failAttempt(session, statusBefore); onUnexpectedError(error); } @@ -154,13 +213,23 @@ export class SessionRemoteConnection extends Disposable { private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, reader: IReader): string | undefined { const attempt = this._attempt.read(reader); - if (attempt?.kind !== 'active' || attempt.session !== session) { + if (attempt?.kind !== 'active' || attempt.session !== session || status?.kind === 'connected') { return undefined; } - return attempt.message - ?? (status?.kind === 'connecting' || status?.kind === 'reconnecting' - ? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection...") - : undefined); + if (attempt.message !== undefined) { + return attempt.message; + } + // Between starting an attempt and the host reporting `connecting`, the + // status is still the one the attempt started from. Treat that window as + // in flight so the recovery action cannot flash back into view. A status + // that actually changed means the host reported something newer — a fresh + // failure, say — which wins over this placeholder. + const settling = status?.kind === 'connecting' + || status?.kind === 'reconnecting' + || isSameRemoteConnectionStatus(status, attempt.statusBefore); + return settling + ? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection...") + : undefined; } private _getRemoteHostUnavailableContent(reader: IReader): IRemoteHostUnavailableEmptyStateContent | undefined { @@ -182,11 +251,27 @@ export class SessionRemoteConnection extends Disposable { }; } const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); - if (progressMessage) { + const autoConnectPending = this._autoConnectPending.read(reader); + const canStartHost = !!provider && isAgentHostProvider(provider) && !!provider.connect; + const autoConnect = canStartHost && provider.autoConnect + ? { + label: provider.autoConnect.label, + checked: provider.autoConnect.enabled.read(reader), + onChange: (checked: boolean) => provider.autoConnect!.setEnabled(checked), + } + : undefined; + const attempt = this._attempt.read(reader); + const startedFromStoppedHost = autoConnectPending + || (attempt?.kind === 'active' + && attempt.session === session + && attempt.statusBefore?.kind === 'disconnected' + && attempt.statusBefore.reason === SessionRemoteConnectionFailureReason.HostNotRunning); + if (progressMessage || autoConnectPending) { return { title: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel), description: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel), - progress: progressMessage, + progress: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + autoConnect: startedFromStoppedHost ? autoConnect : undefined, }; } if (!status || status.kind !== 'disconnected') { @@ -202,6 +287,7 @@ export class SessionRemoteConnection extends Disposable { run: () => this.connect(), } : undefined, + autoConnect, }; } return { @@ -220,8 +306,13 @@ export class SessionRemoteConnection extends Disposable { const provider = this._sessionsProvidersService.getProvider(session.providerId); const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); - if (progressMessage) { - return { icon: Codicon.sync, message: progressMessage }; + // Mirror the recovery state: while an automatic start is pending the + // action must not appear, even for the frame before the attempt registers. + if (progressMessage || this._autoConnectPending.read(reader)) { + return { + icon: Codicon.sync, + message: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + }; } if (!status || status.kind === 'connected' || status.kind === 'connecting' || status.kind === 'incompatible') { diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 692b9819af886d..9987ad8ea297e7 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -31,6 +31,21 @@ export interface IAgentMergeClientState { readonly overrides?: AgentMergeSessionOverrides; } +/** + * Opt-in policy offered on a remote host's recovery surface: when enabled, + * opening a chat whose host is not running starts the host instead of + * waiting for the user to click. Scoped to the host *kind* rather than an + * individual host, so the label names the kind ("WSL", not "Ubuntu-24.04") + * and toggling it from any one host applies to all hosts of that kind. + * Providers choose how the value is backed. + */ +export interface IAgentHostAutoConnect { + /** Checkbox label, e.g. "Automatically Start WSL When Opening Chats". */ + readonly label: string; + readonly enabled: IObservable; + setEnabled(enabled: boolean): void; +} + /** * Declares that a provider is one of many interchangeable members of a single * user-facing host. Members collapse into one `IAgentHostFilterEntry` that @@ -126,6 +141,12 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { * it. Present on remote providers that manage their own transport. */ disconnect?(): Promise; + /** + * Kind-scoped auto-start policy surfaced on the recovery screen. Present + * on remote providers whose host can be started locally; omitted where + * starting is not something VS Code can do. + */ + readonly autoConnect?: IAgentHostAutoConnect; /** * When `true`, the workspace picker keeps this provider's browse diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts index d3182933a849a3..5c32a10c622f2e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts @@ -9,7 +9,7 @@ import { type IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostCon import { type IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { type IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { type INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { type IAgentHostConnectProgress } from '../../../../common/agentHostSessionsProvider.js'; +import { type IAgentHostAutoConnect, type IAgentHostConnectProgress } from '../../../../common/agentHostSessionsProvider.js'; import { type ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; @@ -19,6 +19,7 @@ export interface IEntryDrivenProviderOptions { readonly connectOnDemand?: () => Promise; readonly disconnectOnDemand?: () => Promise; readonly onDidReportConnectProgress?: Event; + readonly autoConnect?: IAgentHostAutoConnect; readonly initialStatus?: RemoteAgentHostConnectionStatus; readonly preferenceKey?: string; } @@ -99,6 +100,7 @@ export abstract class EntryDrivenProviderContribution extends Disposable { connectOnDemand: options.connectOnDemand, disconnectOnDemand: options.disconnectOnDemand, onDidReportConnectProgress: options.onDidReportConnectProgress, + autoConnect: options.autoConnect, preferenceKey: options.preferenceKey, }); if (options.initialStatus !== undefined) { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 293dada061637f..c10dac504457c4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -13,6 +13,7 @@ import { type AgentProvider, type AuthenticateParams, type AuthenticateResult } import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TunnelAgentHostsSettingId } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { WslAutoStartSettingId } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { CloudSandboxEnabledSettingId } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/agentHost/common/agentHostResourceService.js'; import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -622,6 +623,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [WslAutoStartSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.wsl.autoStart', "Automatically start a WSL distribution when opening a chat whose distribution is not running. When disabled, the chat shows a Start button instead."), + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, [RemoteAgentHostsSettingId]: { type: 'array', items: { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 2806099590e0cf..70e710087f9604 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -34,7 +34,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostAutoConnect, IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -73,6 +73,8 @@ export interface IRemoteAgentHostSessionsProviderConfig { readonly disconnectOnDemand?: () => Promise; /** Optional progress messages during on-demand connect. */ readonly onDidReportConnectProgress?: Event; + /** Optional kind-scoped policy for automatically starting the host. */ + readonly autoConnect?: IAgentHostAutoConnect; /** * Set when the host addresses sessions under a scheme that differs from its agent provider, as * the cloud sandbox host does (sessions are `ahp-session:/` while the agent is `copilot`). @@ -138,6 +140,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly canConnectOnDemand: boolean; readonly onDidReportConnectProgress: Event | undefined; + readonly autoConnect?: IAgentHostAutoConnect; readonly automations: ISessionsProviderAutomations; private readonly _automationStore: ReconnectableAgentHostAutomationStore; @@ -236,6 +239,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._defaultChangesetKind = config.defaultChangesetKind; this._devContainerWorktreeScope = config.devContainerWorktreeScope; this.onDidReportConnectProgress = config.onDidReportConnectProgress; + this.autoConnect = config.autoConnect; this.canConnectOnDemand = !!config.connectOnDemand; this._register(this._onDidChangeResourceLabelHomes(() => this.updateResourceLabelHomes())); this.updateResourceLabelHomes(); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts index e161c7685a3656..c6df198dbef18d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { IntervalTimer } from '../../../../../base/common/async.js'; -import { isCancellationError } from '../../../../../base/common/errors.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { localize } from '../../../../../nls.js'; import { type IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, getEntryAddress, getEntryTypeConfig } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { IWSLRemoteAgentHostService, WSL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; +import { IWSLRemoteAgentHostService, WSL_ADDRESS_PREFIX, WslAutoStartSettingId } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { type IAgentHostAutoConnect } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ManagedReconnectAgentHostContribution } from './managedReconnectAgentHostContribution.js'; @@ -39,6 +42,14 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut protected readonly _entryType = RemoteAgentHostEntryType.WSL; + private readonly _autoConnect: IAgentHostAutoConnect = { + label: localize('wslAgentHost.autoStart', "Automatically Start WSL When Opening Chats"), + enabled: observableConfigValue(WslAutoStartSettingId, false, this._configurationService), + setEnabled: enabled => { + this._configurationService.updateValue(WslAutoStartSettingId, enabled).catch(onUnexpectedError); + }, + }; + constructor( @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @IWSLRemoteAgentHostService private readonly _wslService: IWSLRemoteAgentHostService, @@ -117,6 +128,7 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut connectOnDemand: () => this._connectWSLOnDemand(distro, entry.name, address), disconnectOnDemand: () => this._disconnectWSLOnDemand(distro, address), onDidReportConnectProgress: this._wslService.onDidReportConnectProgress, + autoConnect: this._autoConnect, }; } @@ -126,12 +138,15 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut if (!inFlight) { break; } + this._logService.info(`[WSLAgentHost] connectOnDemand: awaiting in-flight reconnect for ${distro}`); await inFlight.catch(() => undefined); const live = this._remoteAgentHostService.connections.find(connection => connection.address === address); if (live && RemoteAgentHostConnectionStatus.isConnected(live.status)) { + this._logService.info(`[WSLAgentHost] connectOnDemand: ${distro} connected by in-flight reconnect`); return; } } + this._logService.info(`[WSLAgentHost] connectOnDemand: starting user-initiated reconnect for ${distro}`); this._reconnectStates.get(distro)?.resetForResume(); await this._attemptWSLReconnect(distro, name, address, true); } diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index dba0a2e6788b68..aa73567d2526b8 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -18,7 +18,7 @@ import { TestInstantiationService } from '../../../platform/instantiation/test/c import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; import { AbstractChatView, ChatViewKind } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; -import { type IAgentHostConnectProgress, IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; +import { type IAgentHostAutoConnect, type IAgentHostConnectProgress, IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -172,6 +172,12 @@ class TestAgentHostProvider extends mock() { override readonly id = 'agenthost-test'; override readonly label = 'WSL: Ubuntu'; override readonly remoteAddress = 'wsl:Ubuntu'; + private readonly _autoConnectEnabled = observableValue(this, false); + override readonly autoConnect: IAgentHostAutoConnect = { + label: 'Automatically Start WSL: Ubuntu', + enabled: this._autoConnectEnabled, + setEnabled: enabled => this._autoConnectEnabled.set(enabled, undefined), + }; private readonly _onDidReportConnectProgress = new Emitter(); override readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event; connectCalls = 0; @@ -229,17 +235,22 @@ function readBanner(view: ChatGroupsView): { readonly visible: boolean; readonly }; } -function readRemoteHostUnavailableState(view: ChatGroupsView): { readonly visible: boolean; readonly title: string | undefined; readonly description: string | undefined; readonly progress: string | undefined; readonly action: string | undefined } { +function readRemoteHostUnavailableState(view: ChatGroupsView): { readonly visible: boolean; readonly title: string | undefined; readonly description: string | undefined; readonly progress: string | undefined; readonly action: string | undefined; readonly actionHidden: boolean; readonly autoConnect: string | undefined; readonly autoConnectChecked: boolean; readonly autoConnectHidden: boolean } { const state = view.element.querySelector('.remote-host-unavailable-empty-state'); // The action container is always present and hidden when there is no action, // so report the label only while it is actually offered. const action = state?.querySelector('.remote-host-unavailable-empty-state-action'); + const autoConnect = state?.querySelector('.remote-host-unavailable-empty-state-auto-connect'); return { visible: !state?.classList.contains('hidden'), title: state?.querySelector('.remote-host-unavailable-empty-state-title')?.textContent ?? undefined, description: state?.querySelector('.remote-host-unavailable-empty-state-description')?.textContent ?? undefined, progress: state?.querySelector('.remote-host-unavailable-empty-state-progress:not(.hidden)')?.textContent ?? undefined, action: action && !action.classList.contains('hidden') ? action.textContent ?? undefined : undefined, + actionHidden: action?.classList.contains('hidden') ?? true, + autoConnect: autoConnect && !autoConnect.classList.contains('hidden') ? autoConnect.querySelector('.remote-host-unavailable-empty-state-auto-connect-label')?.textContent ?? undefined : undefined, + autoConnectChecked: autoConnect?.querySelector('.monaco-checkbox')?.getAttribute('aria-checked') === 'true', + autoConnectHidden: autoConnect?.classList.contains('hidden') ?? true, }; } @@ -719,6 +730,10 @@ suite('Sessions - ChatGroupsView', () => { description: 'WSL: Ubuntu is not running.', progress: undefined, action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, }, banner: false, }, @@ -729,6 +744,10 @@ suite('Sessions - ChatGroupsView', () => { description: 'Cannot reach WSL: Ubuntu.', progress: undefined, action: undefined, + actionHidden: true, + autoConnect: undefined, + autoConnectChecked: false, + autoConnectHidden: true, }, banner: false, }, @@ -744,6 +763,195 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('automatically starts the host again when it drops after an earlier automatic start', async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const status = session.remoteConnectionStatus; + assert.ok(status); + view.setSession(session, options); + await Promise.resolve(); + await Promise.resolve(); + + // The host comes up and the transcript renders, so a later outage is shown + // as a banner rather than the centered state. Each outage gets its own + // automatic attempt: latching for the whole session would leave a dropped + // host sitting behind a manual button. + status.set({ kind: 'connected' }, undefined); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + const connectsWhileConnected = provider.connectCalls; + + provider.connectGate = new DeferredPromise().p; + status.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, undefined); + + assert.deepStrictEqual({ + connectsWhileConnected, + connectCalls: provider.connectCalls, + banner: readBanner(view), + }, { + connectsWhileConnected: 1, + connectCalls: 2, + banner: { visible: true, message: 'Waiting for agent host connection...', action: undefined }, + }); + }); + + test('automatically starts a stopped host without exposing the recovery action', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const connect = new DeferredPromise(); + provider.connectGate = connect.p; + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 1, + state: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Waiting for agent host connection...', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('shows the recovery action without starting a stopped host when auto-connect is disabled', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 0, + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + }); + }); + + test('toggles auto-connect exactly once when its checkbox is clicked', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const connect = new DeferredPromise(); + provider.connectGate = connect.p; + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + const checkbox = view.element.querySelector('.remote-host-unavailable-empty-state-auto-connect .monaco-checkbox'); + assert.ok(checkbox); + checkbox.click(); + + assert.deepStrictEqual({ + autoConnectEnabled: provider.autoConnect.enabled.get(), + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + autoConnectEnabled: true, + connectCalls: 1, + state: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Waiting for agent host connection...', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('does not retrigger an automatic start when the connect resolves without reaching the host', async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + // The provider resolves without the host coming up, so the status stays + // stopped. The automatic start is latched per session rather than on the + // attempt, which a resolved attempt clears — otherwise this would spin. + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 1, + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('offers the recovery action after an automatic connection attempt fails', async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const connect = new DeferredPromise(); + provider.connectGate = connect.p; + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(() => { }); + try { + connect.error(new Error('Expected automatic connect failure')); + await Promise.resolve(); + await Promise.resolve(); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 1, + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + test('shows only its host connection progress and disposes listeners with its session and view', async () => { const { sessionsProvidersService, view } = createHarness(disposables); const provider = new TestAgentHostProvider(); @@ -786,12 +994,20 @@ suite('Sessions - ChatGroupsView', () => { listenerBeforeDispose, listenerAfterDispose, }, { + // Another host's progress is ignored, so the attempt still shows only + // its own placeholder. It stays on the connecting presentation rather + // than falling back to the action: an in-flight attempt must not flash + // the recovery button while the host has yet to report `connecting`. otherHostProgress: { visible: true, - title: 'Unable to Connect to WSL: Ubuntu', - description: 'WSL: Ubuntu is not running.', - progress: undefined, - action: 'Start WSL: Ubuntu', + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Waiting for agent host connection...', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, }, ownHostProgress: { visible: true, @@ -799,6 +1015,10 @@ suite('Sessions - ChatGroupsView', () => { description: 'Starting WSL: Ubuntu.', progress: 'Downloading server (80%)', action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, }, completedAttempt: { visible: true, @@ -806,6 +1026,10 @@ suite('Sessions - ChatGroupsView', () => { description: 'WSL: Ubuntu is not running.', progress: undefined, action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, }, listenerAfterAttempt: false, listenerAfterSessionChange: false, diff --git a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts index 1431eb90991fd4..00aa9d3efa5728 100644 --- a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts +++ b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts @@ -13,6 +13,17 @@ export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/ title: 'Unable to Connect to WSL: Ubuntu', description: 'WSL: Ubuntu is not running.', action: { label: 'Start WSL: Ubuntu', run: () => { } }, + autoConnect: { label: 'Automatically Start WSL: Ubuntu', checked: false, onChange: () => { } }, + }), + }), + + HostNotRunningAutoStart: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + action: { label: 'Start WSL: Ubuntu', run: () => { } }, + autoConnect: { label: 'Automatically Start WSL: Ubuntu', checked: true, onChange: () => { } }, }), }), From 1ee018b8939e6fc9ec45220b14cde9b860cd85a2 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 2 Sep 2026 13:37:14 -0700 Subject: [PATCH 3/6] Agent Host: show reconnect backoff and offer a manual retry While a protocol client waits out its exponential backoff the banner now reads "Reconnecting to in 5s" and counts down, with a Try Now action that skips the remaining delay. Try Now accelerates the client's in-place retry rather than redialling, so the outbox and session state survive. It falls back to a fresh dial only when there is no client to accelerate, which happens now that a rejected factory retains a client-less entry. The backoff deadline travels on the `reconnecting` status. The client stays in that state across rounds, so the deadline is refreshed through a dedicated `onDidScheduleReconnect` event rather than by re-firing the connection-state event: consumers of that event do real work per transition, and repeating it each round would have unclear blast radius. Also offers a Retry action on the generic "Cannot reach " state, on both the banner and the centered recovery surface. A tunnel that dies is usually transient. This stays manual: unlike a stopped WSL distro there is nothing local to start, so retrying automatically would only hammer an unreachable endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 42 ++++++++- .../browser/remoteAgentHostServiceImpl.ts | 22 ++++- .../common/remoteAgentHostService.ts | 30 +++++- .../agentHostProtocolClient.test.ts | 68 ++++++++++++++ .../remoteAgentHostService.test.ts | 93 +++++++++++++++++++ .../browser/parts/sessionRemoteConnection.ts | 47 +++++++++- .../common/agentHostSessionsProvider.ts | 5 + .../browser/baseAgentHostSessionsProvider.ts | 7 +- .../remoteAgentHostSessionsProvider.ts | 4 + .../services/sessions/common/session.ts | 2 +- .../test/browser/chatGroupsView.test.ts | 70 +++++++++++++- 11 files changed, 381 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 3113d9a5f80dcc..9b552b37009c12 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -156,6 +156,8 @@ interface IReconnectState { attempt: number; /** Timer for the next scheduled attempt, if any. */ timeoutHandle: ReturnType | undefined; + /** Deadline for the next scheduled attempt, if any. */ + nextAttemptAt: number | undefined; } /** @@ -256,6 +258,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _onDidChangeConnectionState = this._register(new Emitter()); readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private readonly _onDidScheduleReconnect = this._register(new Emitter()); + readonly onDidScheduleReconnect = this._onDidScheduleReconnect.event; /** * Discriminated state union. Read via narrowing (`_state.kind === ...`); @@ -346,6 +350,13 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return this._state.kind; } + /** Deadline for the next scheduled reconnect attempt, if one is pending. */ + get nextReconnectAt(): number | undefined { + return this._state.kind === AgentHostClientState.Reconnecting + ? this._state.reconnect.nextAttemptAt + : undefined; + } + /** * The latest `initialize` response from the host, or `undefined` if * the handshake has not completed yet. Exposed observably so callers can @@ -473,6 +484,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (this._state.kind === next.kind) { return; } + if (this._state.kind === AgentHostClientState.Reconnecting) { + const reconnect = this._state.reconnect; + if (reconnect.timeoutHandle !== undefined) { + clearTimeout(reconnect.timeoutHandle); + reconnect.timeoutHandle = undefined; + } + reconnect.nextAttemptAt = undefined; + } this._state = next; this._onDidChangeConnectionState.fire(next.kind); } @@ -487,7 +506,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } private _newReconnectState(): IReconnectState { - return { gate: this._newReconnectGate(), outbox: [], attempt: 0, timeoutHandle: undefined }; + return { gate: this._newReconnectGate(), outbox: [], attempt: 0, timeoutHandle: undefined, nextAttemptAt: undefined }; } override dispose(): void { @@ -680,6 +699,24 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return true; } + /** + * Skips the remaining backoff and retries immediately. Returns `false` when + * there is no pending retry to accelerate. + */ + reconnectNow(): boolean { + if (this._state.kind !== AgentHostClientState.Reconnecting || this._state.reconnect.timeoutHandle === undefined) { + return false; + } + const reconnect = this._state.reconnect; + clearTimeout(reconnect.timeoutHandle); + reconnect.timeoutHandle = undefined; + reconnect.nextAttemptAt = undefined; + reconnect.attempt = 0; + this._onDidScheduleReconnect.fire(); + void this._attemptReconnect(); + return true; + } + private _scheduleReconnect(userInitiated = false): void { if (this._state.kind !== AgentHostClientState.Reconnecting || !this._transportFactory) { return; @@ -703,12 +740,15 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect const attempt = reconnect.attempt + 1; const delay = computeReconnectDelay(this._reconnectPolicy, attempt); this._logService.info(`[RemoteAgentHostProtocol] Reconnecting to ${this._address} in ${delay}ms (attempt ${attempt}).`); + reconnect.nextAttemptAt = Date.now() + delay; reconnect.timeoutHandle = setTimeout(() => { if (this._state.kind === AgentHostClientState.Reconnecting) { this._state.reconnect.timeoutHandle = undefined; + this._state.reconnect.nextAttemptAt = undefined; } void this._attemptReconnect(); }, delay); + this._onDidScheduleReconnect.fire(); } private async _attemptReconnect(): Promise { diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index e87397048e4f76..622809cdfdad09 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -365,6 +365,17 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo void this._connectTo(entryToReconnect, { userInitiated }); } + /** + * Skips a protocol client's pending backoff, or starts a fresh user-initiated dial. + */ + reconnectNow(address: string): void { + const normalized = normalizeRemoteAgentHostAddress(address); + if (this._entries.get(normalized)?.client?.reconnectNow()) { + return; + } + this.reconnect(normalized, true); + } + async waitForConnection(address: string): Promise { if (this._store.isDisposed) { throw new Error('Remote agent host service is disposed.'); @@ -650,6 +661,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // Surface self-healing transport drops separately so outer reconnect // loops do not replace the protocol client while it restores itself. + store.add(client.onDidScheduleReconnect(() => { + // The client stays `reconnecting` across backoff rounds, so only this + // event reports that the deadline moved. + if (!isCurrentEntry() || entry.status.kind !== 'reconnecting') { + return; + } + entry.status = RemoteAgentHostConnectionStatus.reconnectingUntil(client.nextReconnectAt); + this._onDidChangeConnections.fire(); + })); store.add(client.onDidChangeConnectionState(state => { if (!isCurrentEntry()) { return; @@ -657,7 +677,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo switch (state) { case 'reconnecting': entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.reconnecting; + entry.status = RemoteAgentHostConnectionStatus.reconnectingUntil(client.nextReconnectAt); this._onDidChangeConnections.fire(); break; case 'connected': diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index fa0f7d19b71588..1059488c0f9563 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -36,7 +36,11 @@ export type RemoteAgentHostConnectionStatus = * preserving session state. Distinct from `connecting` (initial dial) and * `disconnected` (no connection, nothing in flight). */ - | { readonly kind: 'reconnecting' } + | { + readonly kind: 'reconnecting'; + /** When the next automatic attempt fires, if one is scheduled. Absent while an attempt is in flight. */ + readonly nextAttemptAt?: number; + } | { readonly kind: 'disconnected'; readonly reason: AgentHostTransportFailureReason } | { readonly kind: 'incompatible'; @@ -62,6 +66,12 @@ export namespace RemoteAgentHostConnectionStatus { export const connecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'connecting' }); /** Singleton "reconnecting" status. */ export const reconnecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'reconnecting' }); + /** Build a reconnecting status carrying its backoff deadline. */ + export function reconnectingUntil(nextAttemptAt: number | undefined): RemoteAgentHostConnectionStatus { + return nextAttemptAt === undefined + ? reconnecting + : Object.freeze({ kind: 'reconnecting', nextAttemptAt }); + } /** Singleton "disconnected" status. */ export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected', reason: AgentHostTransportFailureReason.Unknown }); /** Build a disconnected status with a machine-readable reason. */ @@ -250,9 +260,20 @@ export type RemoteAgentHostProtocolClientState = 'connecting' | 'incompatible' | */ export interface IRemoteAgentHostProtocolClient extends IAgentConnection, IDisposable { readonly defaultDirectory: string | undefined; + /** Deadline for the next scheduled reconnect attempt, if one is pending. */ + readonly nextReconnectAt: number | undefined; readonly onDidClose: Event; readonly onDidChangeConnectionState: Event; + /** + * Fires whenever the pending reconnect schedule changes — a backoff being + * armed, or cleared by an immediate retry. Separate from + * {@link onDidChangeConnectionState} because the client state is still + * `reconnecting` throughout, and consumers of that event do real work on + * each transition that must not be repeated per backoff round. + */ + readonly onDidScheduleReconnect: Event; connect(): Promise; + reconnectNow(): boolean; notifyTransportClosed(): void; triggerVscodeUpgrade(method: string): Promise; } @@ -711,6 +732,12 @@ export interface IRemoteAgentHostService { * with reset backoff. */ reconnect(address: string, userInitiated?: boolean): void; + /** + * Skips a pending reconnect backoff for this address and retries at once. + * Prefers the protocol client's in-place retry, which preserves session + * state, and falls back to a fresh dial when there is no client to accelerate. + */ + reconnectNow(address: string): void; /** * Force the protocol client at `address` (if any) to treat its @@ -775,6 +802,7 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService { } async removeRemoteAgentHost(_address: string): Promise { } reconnect(_address: string, _userInitiated?: boolean): void { } + reconnectNow(_address: string): void { } notifyConnectionClosed(_address: string): void { } getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; } async triggerServerUpgrade(): Promise { diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 240ff3b9e60fc6..d6427cb8dc1eb1 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -2443,6 +2443,74 @@ suite('AgentHostProtocolClient', () => { }); }); + test('reports the deadline for each scheduled reconnect backoff', async function () { + this.timeout(10_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 60_000, + maxDelayMs: 60_000, + maxAttempts: 3, + }; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + const reconnectDeadlines: (number | undefined)[] = []; + const stateListener = client.onDidChangeConnectionState(state => { + if (state === AgentHostClientState.Reconnecting) { + reconnectDeadlines.push(client.nextReconnectAt); + } + }); + + transports[0].fireClose(); + const firstDeadline = client.nextReconnectAt; + assert.ok(firstDeadline !== undefined); + await timeout(reconnectPolicy.initialDelayMs); + transports[1].connectDeferred.error(new Error('reconnect failed')); + await flushMicrotasks(); + const secondDeadline = client.nextReconnectAt; + assert.ok(secondDeadline !== undefined); + + assert.deepStrictEqual(reconnectDeadlines, [undefined, firstDeadline, secondDeadline]); + stateListener.dispose(); + client.dispose(); + }); + }); + + test('reconnectNow clears a pending backoff and retries immediately', async function () { + this.timeout(10_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 60_000, + maxDelayMs: 60_000, + maxAttempts: 3, + }; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireClose(); + assert.strictEqual(client.reconnectNow(), true); + await timeout(reconnectPolicy.initialDelayMs - 1); + + assert.deepStrictEqual({ + nextReconnectAt: client.nextReconnectAt, + transportCount: transports.length, + }, { + nextReconnectAt: undefined, + transportCount: 2, + }); + client.dispose(); + }); + }); + + test('reconnectNow returns false when no reconnect backoff is pending', () => { + const { client } = createFactoryClient(); + + assert.strictEqual(client.reconnectNow(), false); + }); + test('does not automatically reconnect when the policy disables automatic restore', async () => { const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { autoRestore: false, diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 01d198142fa48e..29be6db51e0e22 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -58,11 +58,16 @@ class MockProtocolClient extends Disposable { readonly onDidNotification = Event.None; private readonly _onDidChangeConnectionState = this._register(new Emitter()); readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private readonly _onDidScheduleReconnect = this._register(new Emitter()); + readonly onDidScheduleReconnect = this._onDidScheduleReconnect.event; readonly onDidReceiveOtlpLogs = Event.None; readonly connectionState = 'connecting' as const; readonly initializeResult = undefined; readonly telemetryCapabilities = undefined; readonly triggerVscodeUpgradeCalls: string[] = []; + nextReconnectAt: number | undefined; + reconnectNowCalls = 0; + reconnectNowResult = false; public connectDeferred = new DeferredPromise(); @@ -74,6 +79,11 @@ class MockProtocolClient extends Disposable { return this.connectDeferred.p; } + reconnectNow(): boolean { + this.reconnectNowCalls++; + return this.reconnectNowResult; + } + async triggerVscodeUpgrade(method: string) { this.triggerVscodeUpgradeCalls.push(method); return { ok: true, upgradeStarted: true }; @@ -86,6 +96,10 @@ class MockProtocolClient extends Disposable { fireConnectionState(state: 'connecting' | 'reconnecting' | 'connected' | 'incompatible' | 'closed'): void { this._onDidChangeConnectionState.fire(state); } + + fireScheduleReconnect(): void { + this._onDidScheduleReconnect.fire(); + } } class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnectionFactory { @@ -821,6 +835,85 @@ suite('RemoteAgentHostService', () => { await userWait; }); + test('surfaces a protocol reconnect backoff deadline', async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:backoff'); + const client = new MockProtocolClient('cloud:backoff'); + await reconnectStagedConnection(factory, entry, client); + + client.nextReconnectAt = 123_456; + client.fireConnectionState('reconnecting'); + + assert.deepStrictEqual( + service.connections.find(connection => connection.address === 'cloud:backoff')?.status, + RemoteAgentHostConnectionStatus.reconnectingUntil(123_456), + ); + }); + + test('refreshes the backoff deadline as the client reschedules', async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:backoff-refresh'); + const client = new MockProtocolClient('cloud:backoff-refresh'); + await reconnectStagedConnection(factory, entry, client); + + client.nextReconnectAt = 1_000; + client.fireConnectionState('reconnecting'); + const armed = service.connections.find(connection => connection.address === 'cloud:backoff-refresh')?.status; + + // The client stays `reconnecting` across rounds, so only the schedule + // event reports the new deadline. + client.nextReconnectAt = 5_000; + client.fireScheduleReconnect(); + + assert.deepStrictEqual({ + armed, + rescheduled: service.connections.find(connection => connection.address === 'cloud:backoff-refresh')?.status, + }, { + armed: RemoteAgentHostConnectionStatus.reconnectingUntil(1_000), + rescheduled: RemoteAgentHostConnectionStatus.reconnectingUntil(5_000), + }); + }); + + test('prefers a protocol client reconnect over a fresh dial', async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:in-place-reconnect'); + const client = new MockProtocolClient('cloud:in-place-reconnect'); + await reconnectStagedConnection(factory, entry, client); + client.reconnectNowResult = true; + + service.reconnectNow('cloud:in-place-reconnect'); + + assert.deepStrictEqual({ + createdConnectionCount: factory.createdConnectionCount, + reconnectNowCalls: client.reconnectNowCalls, + }, { + createdConnectionCount: 1, + reconnectNowCalls: 1, + }); + }); + + test('falls back to a fresh dial when a retained entry has no client', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const address = getEntryAddress(entry); + factory.stageFailure(entry, new NonReconnectableTransportError('WSL distro is not running.', AgentHostTransportFailureReason.HostNotRunning)); + while (service.connections.find(connection => connection.address === address)?.status.kind !== 'disconnected') { + await Event.toPromise(service.onDidChangeConnections); + } + + const client = new MockProtocolClient(address); + factory.stage(entry, client); + service.reconnectNow(address); + await waitForFactoryConnection(factory, 1); + client.connectDeferred.complete(); + await waitForConnected(); + + assert.strictEqual(factory.createdConnectionCount, 1); + }); + test('keeps an incompatible factory connection addressable for server upgrade', async () => { const factory = createFactory(); const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:incompatible'); diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index 63f180c5b823c2..f1dfb287c1c94e 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -42,6 +42,8 @@ export class SessionRemoteConnection extends Disposable { private readonly _attempt = observableValue(this, undefined); private readonly _progressListener = this._register(new MutableDisposable()); private readonly _deadlineSignal = observableSignal(this); + // Kept separate so countdown ticks cannot restart the reconnecting-banner delay. + private readonly _reconnectCountdownSignal = observableSignal(this); /** * The session an automatic start has already been triggered for, cleared * once the host is reachable again so each outage gets its own attempt. @@ -98,6 +100,23 @@ export class SessionRemoteConnection extends Disposable { return false; }); + private readonly _reconnectCountdownSeconds = derived(this, reader => { + const status = this._getEffectiveStatus(reader); + if (status?.kind !== 'reconnecting' || status.nextAttemptAt === undefined) { + return undefined; + } + + this._reconnectCountdownSignal.read(reader); + const now = Date.now(); + const remaining = status.nextAttemptAt - now; + if (remaining <= 0) { + return undefined; + } + + reader.store.add(new TimeoutTimer(() => this._reconnectCountdownSignal.trigger(undefined), 1_000 - now % 1_000)); + return Math.ceil(remaining / 1_000); + }); + readonly bannerContent: IObservable = derived(this, reader => this._getRemoteConnectionBannerContent(reader)); readonly recoveryContent: IObservable = derived(this, reader => this._getRemoteHostUnavailableContent(reader)); @@ -293,6 +312,15 @@ export class SessionRemoteConnection extends Disposable { return { title: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel), description: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + // An unreachable host is often transient — a dropped tunnel, a sleeping + // machine — so offer a manual retry even though there is nothing local + // to start and so nothing to do automatically. + action: canStartHost + ? { + label: localize('sessionRemoteHost.retry', "Retry"), + run: () => this.connect(), + } + : undefined, }; } @@ -320,10 +348,19 @@ export class SessionRemoteConnection extends Disposable { } if (status.kind === 'reconnecting') { + const seconds = this._reconnectCountdownSeconds.read(reader); return this._reconnectingBannerVisible.read(reader) ? { icon: Codicon.sync, - message: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel), + message: seconds === undefined + ? localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel) + : localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds), + action: seconds !== undefined && provider && isAgentHostProvider(provider) && provider.reconnectNow + ? { + label: localize('sessionRemoteHost.tryNow', "Try Now"), + run: () => provider.reconnectNow?.(), + } + : undefined, } : undefined; } @@ -342,6 +379,14 @@ export class SessionRemoteConnection extends Disposable { return { icon: Codicon.debugDisconnect, message: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + // A dropped tunnel or a sleeping machine is usually transient, so a + // manual retry is worth offering even with nothing local to start. + action: provider && isAgentHostProvider(provider) && provider.connect + ? { + label: localize('sessionRemoteHost.retry', "Retry"), + run: () => this.connect(), + } + : undefined, }; } } diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 9987ad8ea297e7..5a47d165a3349c 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -141,6 +141,11 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { * it. Present on remote providers that manage their own transport. */ disconnect?(): Promise; + /** + * Skips a pending reconnect backoff and retries at once. Present on remote + * providers whose transport is restored by a protocol client. + */ + reconnectNow?(): void; /** * Kind-scoped auto-start policy surfaced on the recovery screen. Present * on remote providers whose host can be started locally; omitted where diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index cf0040528ac08c..4142bbb7402b1d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -593,9 +593,14 @@ function toSessionRemoteConnectionStatus(owner: object, connectionStatus: IObser switch (status.kind) { case 'connected': case 'connecting': - case 'reconnecting': case 'incompatible': return { kind: status.kind }; + case 'reconnecting': + // Omit the key rather than carrying an explicit `undefined`, so a + // plain reconnect stays structurally equal to the no-deadline case. + return status.nextAttemptAt === undefined + ? { kind: status.kind } + : { kind: status.kind, nextAttemptAt: status.nextAttemptAt }; case 'disconnected': switch (status.reason) { case AgentHostTransportFailureReason.Unknown: diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 70e710087f9604..fea5926f422628 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -474,6 +474,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._remoteAgentHostService.reconnect(this.remoteAddress); } + reconnectNow(): void { + this._remoteAgentHostService.reconnectNow(this.remoteAddress); + } + /** * Tear down the active connection for this host. Tunnel-backed providers * use their relay hook; other providers fall back to the generic remote diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index f5815b581f9c50..7002c36acdef20 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -87,7 +87,7 @@ export const enum SessionStatus { export type SessionRemoteConnectionStatus = | { readonly kind: 'connected' } | { readonly kind: 'connecting' } - | { readonly kind: 'reconnecting' } + | { readonly kind: 'reconnecting'; readonly nextAttemptAt?: number } | { readonly kind: 'disconnected'; readonly reason: SessionRemoteConnectionFailureReason } | { readonly kind: 'incompatible' }; diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index aa73567d2526b8..9faae29dc391fd 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -181,6 +181,7 @@ class TestAgentHostProvider extends mock() { private readonly _onDidReportConnectProgress = new Emitter(); override readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event; connectCalls = 0; + reconnectNowCalls = 0; connectGate: Promise | undefined; override async connect(): Promise { @@ -188,6 +189,10 @@ class TestAgentHostProvider extends mock() { await this.connectGate; } + override reconnectNow(): void { + this.reconnectNowCalls++; + } + reportConnectProgress(connectionKey: string, message: string): void { this._onDidReportConnectProgress.fire({ connectionKey, message }); } @@ -676,7 +681,7 @@ suite('Sessions - ChatGroupsView', () => { connectCalls: provider.connectCalls, }, { hostNotRunning: { visible: true, message: 'WSL: Ubuntu is not running.', action: 'Start WSL: Ubuntu' }, - unknownDisconnected: { visible: true, message: 'Cannot reach WSL: Ubuntu.', action: undefined }, + unknownDisconnected: { visible: true, message: 'Cannot reach WSL: Ubuntu.', action: 'Retry' }, connectCalls: 1, }); }); @@ -743,8 +748,8 @@ suite('Sessions - ChatGroupsView', () => { title: 'Cannot Connect to WSL: Ubuntu', description: 'Cannot reach WSL: Ubuntu.', progress: undefined, - action: undefined, - actionHidden: true, + action: 'Retry', + actionHidden: false, autoConnect: undefined, autoConnectChecked: false, autoConnectHidden: true, @@ -1095,6 +1100,65 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('shows a reconnect countdown and retries immediately on demand', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 6_000 }); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + await timeout(1_000); + const banner = readBanner(view); + view.element.querySelector('.session-readonly-banner-action-link')?.click(); + + assert.deepStrictEqual({ banner, reconnectNowCalls: provider.reconnectNowCalls }, { + banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + reconnectNowCalls: 1, + }); + }); + }); + + test('updates the reconnect countdown every second', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 7_000 }); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + await timeout(1_000); + const beforeTick = readBanner(view); + await timeout(1_000); + + assert.deepStrictEqual({ beforeTick, afterTick: readBanner(view) }, { + beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 6s', action: 'Try Now' }, + afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + }); + }); + }); + + test('shows a plain reconnecting banner while a reconnect attempt is in flight', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting' }); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + await timeout(1_000); + + assert.deepStrictEqual(readBanner(view), { + visible: true, + message: 'Reconnecting to WSL: Ubuntu...', + action: undefined, + }); + }); + }); + test('shows the reconnecting banner after a failed connect attempt', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionsProvidersService, view } = createHarness(disposables); From 880df7a81dd51b041a9e72029ca8fdb6d196a3a4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 2 Sep 2026 14:09:12 -0700 Subject: [PATCH 4/6] Agent Host: address review feedback on remote connection state Retaining a disconnected entry turned `connections` from a liveness list into a status catalog, but three consumers still read presence as "connected": cloud sandbox negative reconciliation would never tear down a failed environment, and the cloud sandbox and Dev Container connect-failure paths would skip their cleanup and leak a staged connection. Each now tests the status. Documented the broadened semantics on the interface and removed an orphaned JSDoc block for an accessor that no longer exists. Reverted a stray `1.0.0` entry in the supported-protocol list. It broke the registry's documented first-entry invariant against `PROTOCOL_VERSION` and the handshake test, and had nothing to do with this work. `setSession` now writes in one transaction. These observable writes notify autoruns synchronously, so clearing the gates while the previous session was still selected could start the host being switched away from. The banner explains an incompatible host instead of staying silent. Once a transcript is rendered the centered recovery state is skipped, leaving the banner as the only surface, so suppressing it meant no explanation at all. Accessibility: the banner's live region now announces dedicated text rather than its visible text, so a per-second countdown no longer queues an utterance per tick, and connect progress is announced as it advances. Bootstrap progress discards a queued report before publishing an immediate one. If the event loop stalled past the throttle interval, the stale pending value could land after the newer one and make displayed progress run backwards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../remoteAgentHostBootstrapProgress.ts | 8 ++++++ .../common/remoteAgentHostService.ts | 12 ++++---- .../common/state/protocol/version/registry.ts | 1 - .../agentHostProtocolClient.test.ts | 10 +++---- .../parts/media/sessionReadOnlyBanner.css | 13 +++++++++ .../parts/remoteHostUnavailableEmptyState.ts | 3 ++ .../browser/parts/sessionReadOnlyBanner.ts | 22 +++++++++++++-- .../browser/parts/sessionRemoteConnection.ts | 28 +++++++++++++++---- .../cloudSandboxAgentHostContribution.ts | 3 +- .../browser/cloudSandboxAgentHostService.ts | 10 +++++-- .../browser/devContainerAgentHostService.ts | 6 ++-- 11 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts index ca648778dacd18..79b3f561072f5e 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts @@ -41,6 +41,14 @@ export class RemoteAgentHostBootstrapProgressReporter extends Disposable { const now = Date.now(); if (this._lastReportTime === undefined || now - this._lastReportTime >= this._intervalMs) { + // Discard any queued report first: if the event loop stalled past the + // interval, a stale pending value would otherwise land after this newer + // one and make the displayed progress run backwards. + if (this._timeoutHandle !== undefined) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; + } + this._pendingProgress = undefined; this._lastReportTime = now; this._progress.set(progress, undefined); return; diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 1059488c0f9563..985b50091af0a2 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -687,13 +687,15 @@ export interface IRemoteAgentHostService { /** Fires when a remote connection is established or lost. */ readonly onDidChangeConnections: Event; - /** Currently connected remote addresses with metadata. */ - readonly connections: readonly IRemoteAgentHostConnectionInfo[]; - /** - * Returns the last machine-readable disconnect reason for an address whose - * failed connection is no longer represented in {@link connections}. + * Known remote addresses with metadata. This is a status catalog, not a + * liveness list: an entry is retained after a failed dial so its + * {@link IRemoteAgentHostConnectionInfo.status} — and its disconnect reason — + * stay observable. Callers asking "is this host usable?" must test `status` + * (see `RemoteAgentHostConnectionStatus.isConnected`) rather than presence. */ + readonly connections: readonly IRemoteAgentHostConnectionInfo[]; + /** All remote agent host entries exposed by registered factories, regardless of connection status. */ readonly configuredEntries: readonly IRemoteAgentHostEntry[]; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 9f8ba7ec561f1f..86a7dfeb64226a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -35,7 +35,6 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '1.0.0', '0.9.0', '0.8.0', '0.7.0', diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index d6427cb8dc1eb1..aa03e63be162a1 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -2456,10 +2456,10 @@ suite('AgentHostProtocolClient', () => { const connectPromise = client.connect(); await completeHandshake(transports[0], connectPromise); const reconnectDeadlines: (number | undefined)[] = []; - const stateListener = client.onDidChangeConnectionState(state => { - if (state === AgentHostClientState.Reconnecting) { - reconnectDeadlines.push(client.nextReconnectAt); - } + // The client stays `reconnecting` across rounds, so the schedule + // event — not the state event — reports each new deadline. + const stateListener = client.onDidScheduleReconnect(() => { + reconnectDeadlines.push(client.nextReconnectAt); }); transports[0].fireClose(); @@ -2471,7 +2471,7 @@ suite('AgentHostProtocolClient', () => { const secondDeadline = client.nextReconnectAt; assert.ok(secondDeadline !== undefined); - assert.deepStrictEqual(reconnectDeadlines, [undefined, firstDeadline, secondDeadline]); + assert.deepStrictEqual(reconnectDeadlines, [firstDeadline, secondDeadline]); stateListener.dispose(); client.dispose(); }); diff --git a/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css b/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css index 4f3880b350a1aa..c679b4e873bbdd 100644 --- a/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css +++ b/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css @@ -72,3 +72,16 @@ border-radius: var(--vscode-cornerRadius-small, 2px); } + +/* + * Announced by the banner's live region; the visible text is aria-hidden so a + * ticking countdown does not queue an utterance per update. + */ +.session-readonly-banner .session-readonly-banner-announcement { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} \ No newline at end of file diff --git a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts index 2652e9d0ab4458..ffcb92b8a7f15d 100644 --- a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts +++ b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts @@ -61,6 +61,9 @@ export class RemoteHostUnavailableEmptyState extends Disposable { this._title = dom.append(this.domNode, dom.$('h2.remote-host-unavailable-empty-state-title')); this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description')); this._progress = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-progress.hidden')); + // Connect progress changes while the user waits (waiting → download + // percentage), so announce it politely rather than leaving it silent. + this._progress.setAttribute('role', 'status'); this._actionContainer = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-action.hidden')); // Primary styling: recovering the host is the one thing to do here. this._action = this._register(new Button(this._actionContainer, { ...defaultButtonStyles, title: true })); diff --git a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts index 4907ae90af98f2..a7d1d55d7bb0be 100644 --- a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts +++ b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts @@ -21,6 +21,12 @@ import { localize } from '../../../nls.js'; export interface ISessionReadOnlyBannerContent { readonly icon?: ThemeIcon; readonly message: string; + /** + * Text announced instead of {@link message}. Set this when the visible text + * changes faster than it is worth speaking — a per-second countdown, say — + * so the live region announces a stable summary rather than every tick. + */ + readonly ariaLabel?: string; readonly action?: { readonly label: string; readonly run: () => void }; } @@ -41,6 +47,7 @@ export class SessionReadOnlyBanner extends Disposable { private readonly _icon: HTMLElement; private readonly _text: HTMLElement; + private readonly _announcement: HTMLElement; private readonly _actionContainer: HTMLElement; private readonly _actionDisposables = this._register(new DisposableStore()); @@ -48,15 +55,18 @@ export class SessionReadOnlyBanner extends Disposable { super(); this.domNode = dom.$('.session-readonly-banner'); - // A `role="status"` live region is announced from its text content, so no - // `aria-label` is needed (setting one to the same string would just - // override the accessible name without changing the announcement). + // A `role="status"` live region is announced from its text content. The + // visible text is hidden from that content and mirrored into a dedicated + // element, so text that changes every second can be announced as a stable + // summary instead of queueing an utterance per update. this.domNode.setAttribute('role', 'status'); this._icon = dom.append(this.domNode, dom.$('.session-readonly-banner-icon')); this._icon.setAttribute('aria-hidden', 'true'); this._text = dom.append(this.domNode, dom.$('span.session-readonly-banner-text')); + this._text.setAttribute('aria-hidden', 'true'); + this._announcement = dom.append(this.domNode, dom.$('span.session-readonly-banner-announcement')); this._actionContainer = dom.append(this.domNode, dom.$('span.session-readonly-banner-action')); this.setContent({ message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }); @@ -76,6 +86,12 @@ export class SessionReadOnlyBanner extends Disposable { dom.clearNode(this._icon); this._icon.appendChild(renderIcon(content.icon ?? Codicon.lock)); this._text.textContent = content.message; + // Re-writing identical text would queue a fresh announcement, which is + // exactly what a ticking countdown must avoid. + const announced = content.ariaLabel ?? content.message; + if (this._announcement.textContent !== announced) { + this._announcement.textContent = announced; + } this._actionDisposables.clear(); dom.clearNode(this._actionContainer); diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index f1dfb287c1c94e..c736f267b4d5ed 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -7,7 +7,7 @@ import { TimeoutTimer } from '../../../base/common/async.js'; import { Codicon } from '../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { autorun, derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue } from '../../../base/common/observable.js'; +import { autorun, derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue, transaction } from '../../../base/common/observable.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; @@ -143,9 +143,14 @@ export class SessionRemoteConnection extends Disposable { setSession(session: IActiveSession | undefined): void { this._progressListener.clear(); - this._attempt.set(undefined, undefined); - this._autoConnected.set(undefined, undefined); - this._session.set(session, undefined); + // One transaction: these writes notify autoruns synchronously, and + // clearing the gates while the previous session is still selected would + // let the automatic start fire for the host being switched away from. + transaction(tx => { + this._session.set(session, tx); + this._attempt.set(undefined, tx); + this._autoConnected.set(undefined, tx); + }); } connect(): void { @@ -343,10 +348,19 @@ export class SessionRemoteConnection extends Disposable { }; } - if (!status || status.kind === 'connected' || status.kind === 'connecting' || status.kind === 'incompatible') { + if (!status || status.kind === 'connected' || status.kind === 'connecting') { return undefined; } + if (status.kind === 'incompatible') { + // The centered recovery state is skipped once a transcript is rendered, + // so this banner is the only place the incompatibility can be explained. + return { + icon: Codicon.debugDisconnect, + message: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + }; + } + if (status.kind === 'reconnecting') { const seconds = this._reconnectCountdownSeconds.read(reader); return this._reconnectingBannerVisible.read(reader) @@ -355,6 +369,10 @@ export class SessionRemoteConnection extends Disposable { message: seconds === undefined ? localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel) : localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds), + // The banner is a live region, so the per-second countdown would + // otherwise queue an announcement every tick. Keep the spoken + // text stable and let only the visible text count down. + ariaLabel: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel), action: seconds !== undefined && provider && isAgentHostProvider(provider) && provider.reconnectNow ? { label: localize('sessionRemoteHost.tryNow', "Try Now"), diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index d5917f152ef94c..49c7d2ab6f285a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -316,7 +316,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo if (present.has(address) || this._provisioning.has(address)) { continue; } - const connected = this._remoteAgentHostService.connections.some(c => c.address === address); + const connected = this._remoteAgentHostService.connections.some( + c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status)); if (!connected) { this._teardownEnvironment(address); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index f065cb1fdf0a0f..45ce6c557ec03c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -25,7 +25,7 @@ import { type CloudSandboxConnectResult, type ICloudSandboxClientToken, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; -import { getEntryAddress, IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, type IRemoteAgentHostConnectOptions, type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostCreatedConnection, type IRemoteAgentHostEntry } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getEntryAddress, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, type IRemoteAgentHostConnectOptions, type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostCreatedConnection, type IRemoteAgentHostEntry } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -254,8 +254,12 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${address}`); return address; } catch (error) { - const connectionStillRegistered = this._remoteAgentHostService.connections.some(connection => connection.address === address); - if (token.isCancellationRequested || !connectionStillRegistered) { + // A failed dial now retains a client-less entry, so mere presence no + // longer means the connection survived — require a live one. + const connectionStillLive = this._remoteAgentHostService.connections.some(connection => + connection.address === address + && (RemoteAgentHostConnectionStatus.isConnected(connection.status) || RemoteAgentHostConnectionStatus.isReconnecting(connection.status))); + if (token.isCancellationRequested || !connectionStillLive) { this._connectionFactory.unstageConfiguration(address); await this._remoteAgentHostService.removeRemoteAgentHost(address); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts index 47b9818240e501..644bf381b3c90f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts @@ -263,8 +263,10 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont } catch (error) { providerStore.dispose(); if (stagedAddress !== undefined) { - const connectionStillRegistered = this._remoteAgentHostService.connections.some(connection => connection.address === stagedAddress); - if (token.isCancellationRequested || !connectionStillRegistered) { + // A failed dial now retains a client-less entry, so mere presence no + // longer means the connection survived — require a live one. + const connectionStillLive = this._isConnectedOrReconnecting(stagedAddress); + if (token.isCancellationRequested || !connectionStillLive) { this._connectionFactory.unstageConnection(stagedAddress); await this._remoteAgentHostService.removeRemoteAgentHost(stagedAddress); } From b0c6eef1fe3fb968ca099d49cd8251a978f76f0d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 2 Sep 2026 14:11:19 -0700 Subject: [PATCH 5/6] Agent Host: describe the automatic-start latch scope accurately Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/parts/sessionRemoteConnection.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index c736f267b4d5ed..cadf62c0953f61 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -50,6 +50,11 @@ export class SessionRemoteConnection extends Disposable { * Latched separately from {@link _attempt} because a completed attempt * clears that back to `undefined`; keying the gate on it would let a connect * that resolves without reaching the host retrigger forever. + * + * Scoped to this view. A split session has one instance per chat group, so + * an outage can produce one attempt per group. That stays bounded — each + * instance latches independently — and providers collapse the duplicates: + * `connect()` joins an in-flight dial rather than starting a second one. */ private readonly _autoConnected = observableValue(this, undefined); /** From c8303a9346edfdf9d70807fff85464ecadc2a6ca Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 2 Sep 2026 15:48:15 -0700 Subject: [PATCH 6/6] Agent Host: assign the session connection in the constructor Its field initializer read _instantiationService, a parameter property of the same class, which class-field semantics initialize after field initializers run. Caught by define-class-fields-check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/parts/chatGroupView.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index b6db953ae21d6e..f2a394465a4c7a 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -97,7 +97,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { private readonly _currentView = this._register(new MutableDisposable()); private readonly _contextDisposables = this._register(new DisposableStore()); - private readonly _connection = this._register(this._instantiationService.createInstance(SessionRemoteConnection)); + private readonly _connection: SessionRemoteConnection; /** The configured wording for the archive/unarchive action (Archive vs Delete). */ private readonly _archiveActionWording: IObservable>; @@ -121,6 +121,11 @@ export class ChatGroupView extends Disposable implements ISerializableView { ) { super(); + // Assigned here rather than as a field initializer: `_instantiationService` + // is a parameter property of this class, which class-field semantics + // initialize after the field initializers run. + this._connection = this._register(this._instantiationService.createInstance(SessionRemoteConnection)); + this._archiveActionWording = observableFromEvent( this, configurationService.onDidChangeConfiguration,