mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-11 14:04:49 -05:00
Merge pull request #311472 from microsoft/connor4312/ah-auto-connect
agentHost: auto-connect for tunnels and cached sessions
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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./"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user