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..9b552b37009c12 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'; @@ -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; } /** @@ -248,7 +250,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()); @@ -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 { @@ -555,7 +574,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) { @@ -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 { @@ -779,7 +819,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 +1724,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 +1755,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..622809cdfdad09 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) { @@ -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.'); @@ -438,11 +449,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 +587,27 @@ 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); + // 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(); if (!isTerminalConnectError(err) && !this._store.isDisposed && this._remoteAgentHostsEnabled.get()) { this._scheduleReconnect(address, entryToCreate.connectionToken); } @@ -611,13 +642,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 @@ -628,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; @@ -635,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': @@ -715,9 +757,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..79b3f561072f5e --- /dev/null +++ b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * 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) { + // 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; + } + + 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..985b50091af0a2 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'; @@ -35,8 +36,12 @@ export type RemoteAgentHostConnectionStatus = * preserving session state. Distinct from `connecting` (initial dial) and * `disconnected` (no connection, nothing in flight). */ - | { readonly kind: 'reconnecting' } - | { readonly kind: 'disconnected' } + | { + 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'; /** Human-readable reason from the host (or a synthesised one when the host did not send one). */ @@ -61,8 +66,20 @@ 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' }); + 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,9 +260,20 @@ export type RemoteAgentHostProtocolClientState = 'connecting' | 'incompatible' | */ export interface IRemoteAgentHostProtocolClient extends IAgentConnection, IDisposable { readonly defaultDirectory: string | undefined; - readonly onDidClose: Event; + /** 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; } @@ -659,7 +687,13 @@ export interface IRemoteAgentHostService { /** Fires when a remote connection is established or lost. */ readonly onDidChangeConnections: Event; - /** Currently connected remote addresses with metadata. */ + /** + * 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. */ @@ -700,6 +734,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 @@ -743,7 +783,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; } @@ -763,6 +804,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/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/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/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..aa03e63be162a1 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, }); }); @@ -2423,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)[] = []; + // 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(); + 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, [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 5d87f7057654e2..29be6db51e0e22 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,17 +52,22 @@ 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; 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(); @@ -73,18 +79,27 @@ 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 }; } - fireClose(): void { - this._onDidClose.fire(); + fireClose(reason?: AgentHostTransportFailureReason): void { + this._onDidClose.fire(reason); } fireConnectionState(state: 'connecting' | 'reconnecting' | 'connected' | 'incompatible' | 'closed'): void { this._onDidChangeConnectionState.fire(state); } + + fireScheduleReconnect(): void { + this._onDidScheduleReconnect.fire(); + } } class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnectionFactory { @@ -92,6 +107,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 +129,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 +433,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 +446,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 () => { @@ -801,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'); @@ -829,6 +942,100 @@ 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('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 = { + 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..f2a394465a4c7a 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: SessionRemoteConnection; /** The configured wording for the archive/unarchive action (Archive vs Delete). */ private readonly _archiveActionWording: IObservable>; @@ -106,12 +115,17 @@ 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, ) { 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, @@ -121,17 +135,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 +171,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 +198,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 +257,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 +269,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 +358,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 +372,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..719e7ecc78bfdc --- /dev/null +++ b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * 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, +.remote-host-unavailable-empty-state-auto-connect.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); +} + +.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/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 new file mode 100644 index 00000000000000..ffcb92b8a7f15d --- /dev/null +++ b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { defaultButtonStyles, defaultCheckboxStyles } 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; + }; + /** Optional kind-scoped auto-start policy, rendered beneath the action. */ + readonly autoConnect?: { + readonly label: string; + readonly checked: boolean; + readonly onChange: (checked: boolean) => 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()); + 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(); + + 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')); + // 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 })); + 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 { + return !this.domNode.classList.contains('hidden'); + } + + 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; + } + + 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 = ''; + } 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; + } + + 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 { + 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..a7d1d55d7bb0be 100644 --- a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts +++ b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts @@ -10,25 +10,31 @@ 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; + /** + * 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 }; } /** - * 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,7 +45,9 @@ export class SessionReadOnlyBanner extends Disposable { private _visible = false; + private readonly _icon: HTMLElement; private readonly _text: HTMLElement; + private readonly _announcement: HTMLElement; private readonly _actionContainer: HTMLElement; private readonly _actionDisposables = this._register(new DisposableStore()); @@ -47,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'); - 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._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") }); @@ -72,7 +83,15 @@ 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; + // 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); @@ -96,4 +115,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..cadf62c0953f61 --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -0,0 +1,415 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +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; + +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 statusBefore: SessionRemoteConnectionStatus | 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); + // 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. + * 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); + /** + * 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); + 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; + }); + + 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)); + + 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(); + // 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 { + 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') { + this._attempt.set(undefined, undefined); + } + + 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._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 => { + 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._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); + } + } + + 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 || status?.kind === 'connected') { + return 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 { + 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); + 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 ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + autoConnect: startedFromStoppedHost ? autoConnect : undefined, + }; + } + 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, + autoConnect, + }; + } + 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, + }; + } + + 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); + // 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') { + 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) + ? { + icon: Codicon.sync, + 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"), + run: () => provider.reconnectNow?.(), + } + : undefined, + } + : 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), + // 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 692b9819af886d..5a47d165a3349c 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,17 @@ 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 + * 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/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..4142bbb7402b1d 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,63 @@ 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 '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: + 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 +675,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 +688,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 +805,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 +1024,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 +1153,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 +1330,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 +2120,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 +2131,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 +2949,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 +3014,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 +3477,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/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); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts index 46493a09cbb975..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; } @@ -62,14 +63,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(); @@ -107,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) { @@ -131,11 +125,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 +138,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..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'; @@ -176,7 +177,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 +196,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); @@ -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 75d7f111873a91..fea5926f422628 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'; @@ -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; @@ -150,12 +153,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()); @@ -226,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(); @@ -382,7 +396,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 @@ -460,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/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/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/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..7002c36acdef20 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 nextAttemptAt?: number } + | { 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..9faae29dc391fd 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 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'; 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,53 @@ 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 _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; + reconnectNowCalls = 0; + connectGate: Promise | undefined; + + override async connect(): Promise { + this.connectCalls++; + await this.connectGate; + } + + override reconnectNow(): void { + this.reconnectNowCalls++; + } + + 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 +215,48 @@ 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; 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, + }; } suite('Sessions - ChatGroupsView', () => { @@ -560,4 +648,547 @@ 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: 'Retry' }, + 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', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + banner: false, + }, + unknownDisconnected: { + state: { + visible: true, + title: 'Cannot Connect to WSL: Ubuntu', + description: 'Cannot reach WSL: Ubuntu.', + progress: undefined, + action: 'Retry', + actionHidden: false, + autoConnect: undefined, + autoConnectChecked: false, + autoConnectHidden: true, + }, + banner: false, + }, + chatWithContent: { + state: false, + banner: { + visible: true, + message: 'WSL: Ubuntu is not running.', + action: 'Start WSL: Ubuntu', + }, + }, + connectCalls: 1, + }); + }); + + 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(); + 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, + }, { + // 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: '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, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Downloading server (80%)', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + completedAttempt: { + 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, + }, + 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 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); + 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..00aa9d3efa5728 --- /dev/null +++ b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * 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: () => { } }, + 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: () => { } }, + }), + }), + + 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 {