Merge pull request #311472 from microsoft/connor4312/ah-auto-connect

agentHost: auto-connect for tunnels and cached sessions
This commit is contained in:
Connor Peet
2026-04-20 14:17:25 -07:00
committed by GitHub
parent 7a0f366d3f
commit 4fd4618c7e
7 changed files with 353 additions and 39 deletions

View File

@@ -22,6 +22,12 @@ export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts';
/** Configuration key to enable remote agent host connections. */
export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled';
/**
* Configuration key that controls whether online dev tunnels and
* configured SSH remote agent hosts are auto-connected at startup.
*/
export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect';
export const enum RemoteAgentHostEntryType {
WebSocket = 'websocket',
SSH = 'ssh',

View File

@@ -14,8 +14,8 @@ import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { localize } from '../../../../nls.js';
import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';
import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js';
import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js';
import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js';
import type { IFileEdit, IModelSelection, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js';
@@ -24,12 +24,12 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con
import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js';
import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js';
import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js';
import { diffsEqual, diffsToChanges, mapProtocolStatus } from '../../../common/agentHostDiffs.js';
import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js';
import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js';
import { isSessionConfigComplete } from '../../../common/sessionConfig.js';
import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js';
import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus } from '../../../services/sessions/common/session.js';
import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js';
// ============================================================================
// AgentHostSessionAdapter — shared adapter for local and remote sessions

View File

@@ -197,11 +197,10 @@ export class WorkspacePicker extends Disposable {
return;
}
if (item.remoteProvider && item.browseActionIndex === undefined) {
if (item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) {
// Disconnected tunnel — trigger connection flow
this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel');
} else {
// Disconnected SSH host — show options menu after widget hides
if (!item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) {
// Disconnected SSH host — show options menu after widget hides.
// (Disconnected tunnels are rendered as disabled with a
// refresh toolbar action, so onSelect doesn't fire for them.)
this._showRemoteHostOptionsDelayed(item.remoteProvider);
}
} else if (item.browseActionIndex !== undefined) {
@@ -437,11 +436,27 @@ export class WorkspacePicker extends Disposable {
const status = provider.connectionStatus!.get();
const isConnected = status === RemoteAgentHostConnectionStatus.Connected;
const providerBrowseIndex = allBrowseActions.findIndex(a => a.providerId === provider.id);
const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX);
const toolbarActions: IAction[] = [];
// Gear menu only for SSH hosts, not tunnel providers
if (!provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) {
if (isTunnel) {
// Offline/connecting tunnels: surface a refresh button that
// attempts to (re)connect in case the cached status is stale.
if (!isConnected && providerBrowseIndex >= 0) {
const browseIndex = providerBrowseIndex;
toolbarActions.push(toAction({
id: `workspacePicker.remote.refresh.${provider.id}`,
label: localize('workspacePicker.refreshTunnel', "Attempt to Connect"),
class: ThemeIcon.asClassName(Codicon.refresh),
run: () => {
this.actionWidgetService.hide();
this._executeBrowseAction(browseIndex);
},
}));
}
} else {
// Gear menu only for SSH hosts, not tunnel providers
toolbarActions.push(toAction({
id: `workspacePicker.remote.gear.${provider.id}`,
label: localize('workspacePicker.remoteOptions', "Options"),
@@ -453,15 +468,13 @@ export class WorkspacePicker extends Disposable {
}));
}
const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX);
items.push({
kind: ActionListItemKind.Action,
label: provider.label,
description: this._getStatusDescription(status),
hover: { content: this._getStatusHover(status, provider.remoteAddress) },
group: { title: '', icon: isTunnel ? Codicon.cloud : Codicon.remote },
disabled: isTunnel ? false : !isConnected,
disabled: !isConnected,
item: {
browseActionIndex: isConnected && providerBrowseIndex >= 0 ? providerBrowseIndex : undefined,
remoteProvider: provider,

View File

@@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js';
import * as nls from '../../../../nls.js';
import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js';
import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js';
import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { TunnelAgentHostsSettingId } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js';
@@ -102,7 +102,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
// Reconcile providers when configured entries change
this._register(this._configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) {
if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId) || e.affectsConfiguration(RemoteAgentHostAutoConnectSettingId)) {
this._reconcile();
}
}));
@@ -194,6 +194,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
* sshConfigHost but no active connection.
*/
private _reconnectSSHEntries(): void {
const autoConnect = this._configurationService.getValue<boolean>(RemoteAgentHostAutoConnectSettingId);
const entries = this._remoteAgentHostService.configuredEntries;
for (const entry of entries) {
if (entry.connection.type !== RemoteAgentHostEntryType.SSH || !entry.connection.sshConfigHost) {
@@ -208,6 +209,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
if (hasConnection || this._pendingSSHReconnects.has(sshConfigHost)) {
continue;
}
if (!autoConnect) {
continue;
}
this._pendingSSHReconnects.add(sshConfigHost);
this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${sshConfigHost}`);
this._sshService.reconnect(sshConfigHost, entry.name).then(() => {
@@ -216,6 +220,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
}).catch(err => {
this._pendingSSHReconnects.delete(sshConfigHost);
this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${sshConfigHost}`, err);
// Host is unreachable — unpublish any cached sessions we
// were showing so the UI doesn't list stale entries for a
// host we cannot currently reach.
this._providerInstances.get(address)?.unpublishCachedSessions();
});
}
}
@@ -590,6 +598,13 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
[RemoteAgentHostAutoConnectSettingId]: {
type: 'boolean',
description: nls.localize('chat.remoteAgentHosts.autoConnect', "Automatically connect to online dev tunnel and SSH-configured remote agent hosts on startup. When disabled, cached sessions are still shown but connections are established only on demand."),
default: true,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
'chat.sshRemoteAgentHostCommand': {
type: 'string',
description: nls.localize('chat.sshRemoteAgentHostCommand', "For development: Override the command used to start the remote agent host over SSH. When set, skips automatic CLI installation and runs this command instead. The command must print a WebSocket URL matching ws://127.0.0.1:PORT (optionally with ?tkn=TOKEN) to stdout or stderr./"),

View File

@@ -19,6 +19,7 @@ import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from
import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
@@ -53,6 +54,62 @@ function wellKnownAgentProvider(sessionType: string): string | undefined {
return undefined;
}
/** Storage key prefix for cached session summaries, per remote address. */
const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.';
/** Maximum number of cached session summaries persisted per host. */
const CACHED_SESSIONS_MAX_PER_HOST = 100;
/**
* Serialized shape of an {@link IAgentSessionMetadata} suitable for
* persisting via {@link IStorageService}. URIs are stored as strings
* and diffs are intentionally omitted (they are re-populated when the
* connection refreshes sessions).
*/
interface ISerializedSessionMetadata {
readonly session: string;
readonly startTime: number;
readonly modifiedTime: number;
readonly summary?: string;
readonly model?: IAgentSessionMetadata['model'];
readonly workingDirectory?: string;
readonly isRead?: boolean;
readonly isDone?: boolean;
readonly project?: { readonly uri: string; readonly displayName: string };
}
function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata {
return {
session: meta.session.toString(),
startTime: meta.startTime,
modifiedTime: meta.modifiedTime,
summary: meta.summary,
model: meta.model,
workingDirectory: meta.workingDirectory?.toString(),
isRead: meta.isRead,
isDone: meta.isDone,
project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined,
};
}
function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined {
try {
return {
session: URI.parse(raw.session),
startTime: raw.startTime,
modifiedTime: raw.modifiedTime,
summary: raw.summary,
model: raw.model,
workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined,
isRead: raw.isRead,
isDone: raw.isDone,
project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined,
};
} catch {
return undefined;
}
}
function toLocalProjectUri(uri: URI, connectionAuthority: string): URI {
return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri;
}
@@ -120,11 +177,36 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
private readonly _connectionListeners = this._register(new DisposableStore());
private readonly _connectionAuthority: string;
private readonly _connectOnDemand: (() => Promise<void>) | undefined;
/** Storage key used for persisting {@link _sessionCache} snapshots. */
private readonly _storageKey: string;
/**
* Set when {@link _sessionCache} has changed since the last persist.
* The actual write happens on the next `onWillSaveState` signal from
* {@link IStorageService} so that bursts of notifications do not
* repeatedly re-serialize the whole cache.
*/
private _cacheDirty = false;
/**
* Snapshot of the source metadata for each adapter in {@link _sessionCache},
* keyed by raw session ID. Captured in {@link createAdapter} and re-used by
* {@link _persistCache} to serialize sessions without having to reconstruct
* every `IAgentSessionMetadata` field from observables.
*/
private readonly _metaByRawId = new Map<string, IAgentSessionMetadata>();
/**
* When `true`, the provider has been marked unreachable and sessions are
* hidden from {@link getSessions}, even though {@link _sessionCache} and
* persistent storage are retained. Cleared when a new connection is wired
* up in {@link setConnection}, at which point the cached entries are
* re-announced so the UI can repopulate.
*/
private _unpublished = false;
constructor(
config: IRemoteAgentHostSessionsProviderConfig,
@IFileDialogService private readonly _fileDialogService: IFileDialogService,
@INotificationService private readonly _notificationService: INotificationService,
@IStorageService private readonly _storageService: IStorageService,
@IChatSessionsService chatSessionsService: IChatSessionsService,
@IChatService chatService: IChatService,
@IChatWidgetService chatWidgetService: IChatWidgetService,
@@ -139,6 +221,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
this.id = `agenthost-${this._connectionAuthority}`;
this.label = displayName;
this.remoteAddress = config.address;
this._storageKey = `${CACHED_SESSIONS_STORAGE_PREFIX}${this._connectionAuthority}`;
this.browseActions = [{
label: localize('folders', "Folders"),
@@ -146,6 +229,30 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
providerId: this.id,
run: () => this._browseForFolder(),
}];
this._loadCachedSessions();
this._register(this._onDidChangeSessions.event(e => {
if (this._unpublished) {
return;
}
if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) {
this._cacheDirty = true;
}
for (const removed of e.removed) {
const rawId = this._rawIdFromChatId(removed.sessionId);
if (rawId) {
this._metaByRawId.delete(rawId);
}
}
}));
this._register(this._storageService.onWillSaveState(() => {
if (this._cacheDirty) {
this._persistCache();
this._cacheDirty = false;
}
}));
}
// -- BaseAgentHostSessionsProvider hooks ---------------------------------
@@ -158,6 +265,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER;
const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, provider);
const logicalType = this._logicalSessionTypeForProvider(provider);
this._metaByRawId.set(AgentSession.id(meta.session), meta);
return new AgentHostSessionAdapter(meta, this.id, resourceScheme, logicalType, {
icon: this.icon,
description: new MarkdownString().appendText(this.label),
@@ -175,6 +283,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
return wellKnownAgentProvider(sessionType) ?? sessionType.substring(`remote-${this._connectionAuthority}-`.length);
}
override getSessions(): ISession[] {
return this._unpublished ? [] : super.getSessions();
}
protected override mapWorkingDirectoryUri(uri: URI): URI {
return toAgentHostUri(uri, this._connectionAuthority);
}
@@ -237,6 +349,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
this._sessionStateSubscriptions.clearAndDisposeAll();
this._connection = connection;
this._defaultDirectory = defaultDirectory;
this._unpublished = false;
// Dynamically discover session types from the host's advertised agents.
const rootStateValue = connection.rootState.value;
@@ -256,7 +369,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
/**
* Clear the connection, e.g. when the remote host disconnects.
* Retains the provider registration so it remains visible in the UI.
* Retains the provider registration so it remains visible in the UI,
* and **preserves** the cached session list so previously loaded
* sessions stay visible while we're offline. Callers that know the
* host is unreachable should follow up with {@link unpublishCachedSessions}.
*/
clearConnection(): void {
this._connectionListeners.clear();
@@ -279,19 +395,91 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
this._onDidChangeSessionTypes.fire();
}
const removed: ISession[] = Array.from(this._sessionCache.values());
// Drop only the transient pending/draft session; keep the persisted
// cache so the workspace picker keeps showing offline sessions.
if (this._pendingSession) {
removed.push(this._pendingSession);
const pending = this._pendingSession;
this._pendingSession = undefined;
this._onDidChangeSessions.fire({ added: [], removed: [pending], changed: [] });
}
this._sessionCache.clear();
this._runningSessionConfigs.clear();
// Reset the in-memory cache-initialized flag so a fresh connection
// triggers a full list refresh (which will reconcile against the
// persisted entries we keep on disk).
this._cacheInitialized = false;
}
/**
* Hide cached sessions from the UI without discarding them. Called by the
* host-tracking contributions when they determine the remote host is
* unreachable (tunnel offline or SSH reconnect failed). The in-memory
* cache and persisted storage are left intact so the sessions can be
* restored if the host comes back online in this session, or on the next
* launch. The next {@link setConnection} call re-announces the cached
* entries.
*/
unpublishCachedSessions(): void {
if (this._unpublished) {
return;
}
this._unpublished = true;
const removed: ISession[] = Array.from(this._sessionCache.values());
if (removed.length > 0) {
this._onDidChangeSessions.fire({ added: [], removed, changed: [] });
}
}
/** Load persisted session summaries into {@link _sessionCache}. */
private _loadCachedSessions(): void {
const parsed = this._storageService.getObject(this._storageKey, StorageScope.APPLICATION);
if (!Array.isArray(parsed)) {
return;
}
for (const entry of parsed as readonly ISerializedSessionMetadata[]) {
const meta = deserializeMetadata(entry);
if (!meta) {
continue;
}
const rawId = AgentSession.id(meta.session);
if (this._sessionCache.has(rawId)) {
continue;
}
const cached = this.createAdapter(meta);
this._sessionCache.set(rawId, cached);
}
}
/**
* Persist the current {@link _sessionCache} to storage, capping at
* {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries.
* Mutable fields are read from each adapter's observables and overlaid on
* top of the original metadata snapshot captured in {@link _metaByRawId}.
*/
private _persistCache(): void {
const entries: ISerializedSessionMetadata[] = [];
for (const [rawId, adapter] of this._sessionCache) {
const base = this._metaByRawId.get(rawId);
if (!base) {
continue;
}
entries.push(serializeMetadata({
...base,
summary: adapter.title.get() || base.summary,
modifiedTime: adapter.updatedAt.get().getTime(),
model: adapter.modelSelection ?? base.model,
isRead: adapter.isRead.get(),
isDone: adapter.isArchived.get(),
}));
}
if (entries.length === 0) {
this._storageService.remove(this._storageKey, StorageScope.APPLICATION);
return;
}
entries.sort((a, b) => b.modifiedTime - a.modifiedTime);
const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST);
this._storageService.store(this._storageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER);
}
// -- Session-type sync ---------------------------------------------------
/**
@@ -365,7 +553,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
private async _browseForFolder(): Promise<ISessionWorkspace | undefined> {
// Establish connection on demand if a hook is provided (e.g. tunnel relay)
if (!this._connection && this._connectOnDemand) {
await this._connectOnDemand();
try {
await this._connectOnDemand();
} catch (err) {
this._notificationService.error(localize('connectFailed', "Failed to connect to remote agent host '{0}': {1}", this.label, err instanceof Error ? err.message : String(err)));
return undefined;
}
}
if (!this._connection) {

View File

@@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../
import { isWeb } from '../../../../base/common/platform.js';
import { mainWindow } from '../../../../base/browser/window.js';
import * as nls from '../../../../nls.js';
import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
@@ -42,6 +42,12 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();
private readonly _pendingConnects = new Map<string, Promise<void>>();
private _lastStatusCheck = 0;
/**
* `false` until the first {@link _silentStatusCheck} resolves. Until then
* we keep newly-created providers in the `Connecting` state so the picker
* doesn't briefly show every cached tunnel as "Offline" on startup.
*/
private _initialStatusChecked = false;
/** Previous connection status per address — used to detect Connected→Disconnected transitions. */
private readonly _previousStatuses = new Map<string, RemoteAgentHostConnectionStatus>();
@@ -171,9 +177,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
RemoteAgentHostSessionsProvider, {
address,
name,
connectOnDemand: () => this._connectTunnel(address),
connectOnDemand: () => this._connectTunnel(address, { userInitiated: true }),
},
);
// Surface as "Connecting" until the first silent status check or an
// auto-connect attempt determines the real state; otherwise the picker
// flashes "Offline" for every cached tunnel on startup.
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
store.add(provider);
store.add(this._sessionsProvidersService.registerProvider(provider));
this._providerInstances.set(address, provider);
@@ -190,6 +200,10 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
provider.setConnectionStatus(connectionInfo.status);
} else if (this._pendingConnects.has(address)) {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
} else if (!this._initialStatusChecked) {
// Keep the initial "Connecting" state so the picker doesn't
// flash "Offline" before the first silent status check runs.
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
} else {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);
}
@@ -219,7 +233,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
* Establish a relay connection to a cached tunnel. Called on demand
* when the user invokes the browse action on an online-but-not-connected tunnel.
*/
private _connectTunnel(address: string): Promise<void> {
private _connectTunnel(address: string, options: { readonly userInitiated: boolean }): Promise<void> {
const existing = this._pendingConnects.get(address);
if (existing) {
return existing;
@@ -239,15 +253,16 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
const promise = (async () => {
// Show a progress notification after a short delay so quick
// connects don't flash a notification.
// connects don't flash a notification. Only show for user-initiated
// connects; background auto-connects and reconnects stay silent.
let handle: { close(): void } | undefined;
const timer = setTimeout(() => {
const timer = options.userInitiated ? setTimeout(() => {
handle = this._notificationService.notify({
severity: Severity.Info,
message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name),
progress: { infinite: true },
});
}, 1000);
}, 1000) : undefined;
this._updateConnectionStatuses();
try {
@@ -279,7 +294,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
}
throw err;
} finally {
clearTimeout(timer);
if (timer !== undefined) {
clearTimeout(timer);
}
handle?.close();
this._pendingConnects.delete(address);
this._updateConnectionStatuses();
@@ -412,7 +429,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
}
this._reconnectAttempts.set(address, attempt + 1);
this._connectTunnel(address).catch(() => { /* _connectTunnel already re-schedules on failure */ });
this._connectTunnel(address, { userInitiated: false }).catch(() => { /* _connectTunnel already re-schedules on failure */ });
}, delay);
this._reconnectTimeouts.set(address, timer);
}
@@ -607,6 +624,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
private async _silentStatusCheck(): Promise<void> {
const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);
if (!enabled) {
this._initialStatusChecked = true;
this._updateConnectionStatuses();
return;
}
@@ -618,6 +637,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
onlineTunnels = await this._tunnelService.listTunnels({ silent: true });
} catch {
// No cached token or network error — leave statuses as-is
this._initialStatusChecked = true;
this._updateConnectionStatuses();
return;
}
@@ -643,7 +664,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
// Update online/offline status based on hostConnectionCount.
// For tunnels, Connected means "host is online" (clickable to connect),
// Disconnected means "host is offline". Actual relay connection
// establishment happens when the user clicks the tunnel.
// establishment happens when the user clicks the tunnel (or via
// auto-connect below when enabled).
const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t]));
for (const [address, provider] of this._providerInstances) {
// Skip tunnels that already have an active relay connection
@@ -660,13 +682,18 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected);
} else {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);
// Host is not online — drop any cached sessions we were
// showing for it so the UI doesn't list stale entries.
provider.unpublishCachedSessions();
}
}
// Auto-connect online tunnels that aren't connected yet.
// On web there is no workspace picker to trigger manual connection,
// so we connect eagerly when a tunnel is discovered and online.
if (isWeb) {
// Auto-connect online tunnels that aren't connected yet when the
// user has opted into auto-connect (default on). This mirrors the
// web embedder behaviour where no workspace picker is available
// to trigger manual connection.
const autoConnect = this._configurationService.getValue<boolean>(RemoteAgentHostAutoConnectSettingId);
if (autoConnect) {
for (const tunnel of onlineTunnels) {
if (tunnel.hostConnectionCount > 0) {
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
@@ -674,12 +701,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
);
if (!alreadyConnected) {
this._connectTunnel(address);
this._connectTunnel(address, { userInitiated: false });
}
}
}
}
}
this._initialStatusChecked = true;
this._updateConnectionStatuses();
}
}

View File

@@ -177,7 +177,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string;
};
}
function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; openSession?: boolean }): RemoteAgentHostSessionsProvider {
function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean }): RemoteAgentHostSessionsProvider {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IFileDialogService, {});
@@ -196,7 +196,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne
instantiationService.stub(ILanguageModelsService, {
lookupLanguageModel: () => undefined,
});
instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService()));
instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService()));
const config: IRemoteAgentHostSessionsProviderConfig = {
address: overrides?.address ?? 'localhost:4321',
@@ -204,7 +204,9 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne
};
const provider = disposables.add(instantiationService.createInstance(RemoteAgentHostSessionsProvider, config));
provider.setConnection(connection);
if (!overrides?.noConnection) {
provider.setConnection(connection);
}
return provider;
}
@@ -761,6 +763,61 @@ suite('RemoteAgentHostSessionsProvider', () => {
assert.strictEqual(session!.loading.get(), false);
}));
test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
const storageService = disposables.add(new InMemoryStorageService());
connection.addSession(createSession('keep-me', { summary: 'Keep Me' }));
const provider = createProvider(disposables, connection, { storageService });
await timeout(0);
assert.strictEqual(provider.getSessions().length, 1);
const events: ISessionChangeEvent[] = [];
disposables.add(provider.onDidChangeSessions(e => events.push(e)));
provider.unpublishCachedSessions();
// Sessions are hidden from the listing immediately.
assert.deepStrictEqual(
{
sessionCount: provider.getSessions().length,
eventRemovedTitles: events.flatMap(e => e.removed.map(s => s.title.get())),
},
{ sessionCount: 0, eventRemovedTitles: ['Keep Me'] },
);
// Flush triggers onWillSaveState; the metadata must survive so the
// session re-serializes instead of being dropped from storage.
await storageService.flush();
const provider2 = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true });
assert.deepStrictEqual(
provider2.getSessions().map(s => s.title.get()),
['Keep Me'],
);
}));
test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
connection.addSession(createSession('restore-me', { summary: 'Restore Me' }));
const provider = createProvider(disposables, connection);
await timeout(0);
assert.strictEqual(provider.getSessions().length, 1);
provider.unpublishCachedSessions();
assert.strictEqual(provider.getSessions().length, 0);
// Simulate the host coming back online with a fresh connection that
// still reports the same session.
const reconnected = new MockAgentConnection();
disposables.add(toDisposable(() => reconnected.dispose()));
reconnected.addSession(createSession('restore-me', { summary: 'Restore Me' }));
provider.setConnection(reconnected);
await timeout(0);
assert.deepStrictEqual(
provider.getSessions().map(s => s.title.get()),
['Restore Me'],
);
}));
test('sendAndCreateChat throws for unknown session', async () => {
const provider = createProvider(disposables, connection);
await assert.rejects(