microsoft/vscode · #337354
sessions: Restore sandbox sessions promptly on reload
src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts15 + / 15 −
@@ -11,6 +11,7 @@ // to reach an agent host over one transport; it does not define a new kind of agent host. import { CancellationToken } from '../../../base/common/cancellation.js';+import { Event } from '../../../base/common/event.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { RemoteAgentHostsEnabledSettingId } from './remoteAgentHostService.js';@@ -226,15 +227,16 @@ export interface ICloudSandboxConnectionRequest { export const ICloudSandboxApiService = createDecorator<ICloudSandboxApiService>('cloudSandboxApiService'); -/**- * Client for the Mission Control APIs a cloud sandbox session depends on: connection credentials,- * the environment and task records, and the persisted history. Every call is served by Mission- * Control rather than the sandbox, which is what keeps {@link getSessionHistory} readable after the- * environment is gone.- */+/** Account identity and control-plane APIs for sandbox credentials, discovery, and persisted history. */ export interface ICloudSandboxApiService { readonly _serviceBrand: undefined; + /** Account identity after authentication changes, or undefined when signed out. */+ readonly onDidChangeAccount: Event<string | undefined>;++ /** Resolves an opaque, credential-free account key using the same identity as task requests. */+ getAccountKey(): Promise<string | undefined>;+ /** * Mint a fresh client Web PubSub connection token for a new logical connection. May resolve to a * "waking" result the caller should retry.@@ -254,8 +256,8 @@ export interface ICloudSandboxApiService { */ getEnvironment(environmentId: string, token: CancellationToken): Promise<ICloudSandboxEnvironment>; - /** Enumerate the caller's sandbox-backed cloud sessions, enough to seed session entries. */- listSessions(token: CancellationToken): Promise<ICloudSandboxDiscoveryResult>;+ /** Enumerate sandbox sessions, optionally returning changes since the last successful scan. */+ listSessions(token: CancellationToken, options?: { readonly incremental?: boolean }): Promise<ICloudSandboxDiscoveryResult>; /** * Provision a new sandbox task and its bound session. Mission Control starts no run, so the@@ -270,16 +272,14 @@ export interface ICloudSandboxApiService { getSessionHistory(taskId: string, token: CancellationToken): Promise<IReplayedTaskHistory | undefined>; } -/**- * Outcome of a discovery pass. Only a `complete` result describes the full set of sandbox sessions,- * so only it may be reconciled against — a `partial` result is missing entries that still exist, and- * treating it as authoritative would tear down live sessions.- */+/** Only a complete scan permits removing absent sessions; other results may name explicit removals. */ export type ICloudSandboxDiscoveryResult = /** Every task was scanned and resolved; absent sessions really are gone. */ | { readonly kind: 'complete'; readonly sessions: readonly ICloudSandboxDiscoveredSession[] }- /** Some tasks could not be resolved. Seed what was found, but do not remove anything. */- | { readonly kind: 'partial'; readonly sessions: readonly ICloudSandboxDiscoveredSession[] }+ /** Changes only; sessions absent from this result must be retained. */+ | { readonly kind: 'incremental'; readonly sessions: readonly ICloudSandboxDiscoveredSession[]; readonly removedTaskIds: readonly string[] }+ /** Some tasks could not be resolved; only explicitly removed tasks may be dropped. */+ | { readonly kind: 'partial'; readonly sessions: readonly ICloudSandboxDiscoveredSession[]; readonly removedTaskIds?: readonly string[] } /** Discovery could not run (auth not ready, request failed). Existing state must be left alone. */ | { readonly kind: 'failed'; readonly reason: string }; src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts1 + / 0 −
@@ -91,6 +91,7 @@ suite('AgentHostTelemetryReporter', () => { provider, session, turnId: 'turn', parentTurnId: undefined, parentToolCallId: undefined, subagentTaskModelSource: undefined, timeToFirstProgress: undefined, timeToFirstSubstantiveProgress: undefined, timeToFirstEditMs: undefined, timeToFirstEditClassifierVersion: undefined,+ startedWithSteering: false, receivedSteering: false, totalTime: 100, result: 'success', model: undefined, modelTelemetryKind: undefined, modelSelectionKind: 'default', permissionLevel: undefined, interactionMode: undefined, messageOriginKind: undefined, failure: undefined, isMultiRoot: false, folderCount: 0, billedNanoAiu: undefined, directPromptTokenCount: undefined,src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts80 + / 7 −
@@ -189,6 +189,13 @@ function isWorktreeIsolation(values: Record<string, unknown> | undefined): boole /** Maximum number of cached session summaries persisted per provider. */ const CACHED_SESSIONS_MAX_PER_HOST = 100; +interface IAgentHostSessionDiscoveryMetadata {+ readonly summary?: string;+ readonly modifiedTime?: number;+ /** `null` is a discovery-owned absent project; `undefined` leaves the project to the host. */+ readonly project?: IAgentSessionMetadata['project'] | null;+}+ /** * Serialized shape of an {@link IAgentSessionMetadata} suitable for * persisting via {@link IStorageService}. URIs are stored as strings and only@@ -229,6 +236,11 @@ interface ISerializedSessionMetadata { readonly multiRoot?: ISessionMultiRootMetadata; readonly createdBySession?: IProtocolSessionCreationReference; readonly devContainerWorktree?: IAgentDevContainerWorktreeMetadata;+ readonly discovery?: {+ readonly summary?: string;+ readonly modifiedTime?: number;+ readonly project?: ISerializedSessionMetadata['project'] | null;+ }; } /**@@ -238,7 +250,7 @@ interface ISerializedSessionMetadata { */ const SESSION_STATUS_FLAG_MASK = ProtocolSessionStatus.IsRead | ProtocolSessionStatus.IsArchived; -function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata {+function serializeMetadata(meta: IAgentSessionMetadata, discovery?: IAgentHostSessionDiscoveryMetadata): ISerializedSessionMetadata { return { session: meta.session.toString(), startTime: meta.startTime,@@ -261,9 +273,31 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad multiRoot: readSessionMultiRootMetadata(meta._meta), createdBySession: readSessionCreationReference(meta._meta), devContainerWorktree: readAgentDevContainerWorktreeMetadata(meta._meta),+ discovery: discovery ? {+ summary: discovery.summary,+ modifiedTime: discovery.modifiedTime,+ project: discovery.project ? { uri: discovery.project.uri.toString(), displayName: discovery.project.displayName } : discovery.project,+ } : undefined, }; } +function deserializeDiscoveryMetadata(raw: ISerializedSessionMetadata['discovery'], logService: ILogService): IAgentHostSessionDiscoveryMetadata | undefined {+ try {+ return raw ? {+ summary: raw.summary,+ modifiedTime: raw.modifiedTime,+ project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : raw.project,+ } : undefined;+ } catch (error) {+ logService.warn('[AgentHostSessionsProvider] Reading cached discovery metadata failed.', error);+ return undefined;+ }+}++function sessionProjectsEqual(a: IAgentSessionMetadata['project'] | null, b: IAgentSessionMetadata['project'] | null): boolean {+ return a === b || (!!a && !!b && a.displayName === b.displayName && isEqual(a.uri, b.uri));+}+ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { try { let _meta = withSessionWorkspaceless(undefined, raw.workspaceless === true);@@ -1038,6 +1072,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // actions dispatched on session open (without a full list refresh). // See `_applySessionMetadataFromState` / `applySessionStateMetadata`. private _project: IAgentSessionMetadata['project'];+ /** Discovery-owned field baselines, persisted separately from authoritative host metadata. */+ discoveryMetadata: IAgentHostSessionDiscoveryMetadata | undefined; private _workingDirectories: readonly URI[] | undefined; /** Working-directory set used to resolve session customizations. */ get workingDirectories(): readonly URI[] { return this._workingDirectories ?? []; }@@ -1969,9 +2005,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { let didChange = false; if (Object.prototype.hasOwnProperty.call(metadata, 'project')) { const project = metadata.project;- const projectMatches = this._project === project- || (!!this._project && !!project && this._project.displayName === project.displayName && isEqual(this._project.uri, project.uri));- if (!projectMatches) {+ if (!sessionProjectsEqual(this._project, project)) { this._project = project; didChange = true; }@@ -2023,6 +2057,45 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { */ get project(): IAgentSessionMetadata['project'] { return this._project; } + /** Refresh discovery-owned fields without overwriting fields changed by the host. */+ updateDiscoveryMetadata(metadata: Pick<IAgentSessionMetadata, 'summary' | 'modifiedTime' | 'project'>): boolean {+ const previous = this.discoveryMetadata ?? {+ summary: metadata.summary,+ modifiedTime: metadata.modifiedTime,+ project: this._project ? metadata.project ?? null : null,+ };+ let summary: string | undefined;+ let modifiedTime: number | undefined;+ let project: IAgentHostSessionDiscoveryMetadata['project'];+ let didChange = false;+ transaction(tx => {+ if (previous.summary !== undefined && this.title.get() === previous.summary) {+ summary = metadata.summary ?? previous.summary;+ if (summary !== this.title.get()) {+ this.title.set(summary, tx);+ didChange = true;+ }+ }+ if (previous.modifiedTime !== undefined && this.updatedAt.get().getTime() === previous.modifiedTime) {+ modifiedTime = metadata.modifiedTime;+ if (modifiedTime !== this.updatedAt.get().getTime()) {+ this.updatedAt.set(new Date(modifiedTime), tx);+ didChange = true;+ }+ }+ if (previous.project !== undefined && sessionProjectsEqual(this._project, previous.project ?? undefined)) {+ project = metadata.project ?? null;+ didChange = this.applySessionSummaryWorkspaceMetadata({ project: metadata.project }, tx) || didChange;+ }+ didChange ||= !this.discoveryMetadata+ || previous.summary !== summary+ || previous.modifiedTime !== modifiedTime+ || !sessionProjectsEqual(previous.project, project);+ this.discoveryMetadata = { summary, modifiedTime, project };+ });+ return didChange;+ }+ /** * Assign a project to a session that was materialized without one, recomputing the workspace. * Refuses when the session already has a project.@@ -6264,6 +6337,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement continue; } const cached = this.createAdapter(meta);+ cached.discoveryMetadata = deserializeDiscoveryMetadata(entry.discovery, this._logService); this._sessionCache.set(rawId, cached); } }@@ -6292,8 +6366,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement summary: adapter.title.get() || base.summary, modifiedTime: adapter.updatedAt.get().getTime(), changes: adapter.changesSummary.get(),- // A project assigned by `backfillProject` lives only on the adapter.- project: adapter.project ?? base.project,+ project: adapter.project, // Session-state and summary updates can relocate an existing session. workingDirectories: adapter.workingDirectories, status: withSessionStatusFlag(@@ -6302,7 +6375,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement adapter.isArchived.get()), // Session-state updates can refine presentation metadata without another listing. _meta: sessionMeta,- }));+ }, adapter.discoveryMetadata)); } if (entries.length === 0) { this._storageService.remove(this._sessionCacheStorageKey, StorageScope.APPLICATION);src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md4 + / 0 −
@@ -14,6 +14,10 @@ Kind-specific contributions create and register one provider for each remote hos Agent discovery is dynamic. Changes to a host's advertised agents update the provider's session types without recreating the provider. +Sandbox session discovery is window-owned and does not establish host connections. A full refresh reconciles absent disconnected environments; incremental refreshes retain absent entries and reconcile only explicitly removed or replaced tasks. Both preserve connected and provisioning environments. Failed or cancelled scans must not advance incremental discovery progress.++The sandbox contribution saves a minimal discovery inventory in machine-local profile storage, separately for each authentication provider and account. Once the current account is known, it restores providers and cached rows before awaiting network discovery, without waking environments. Failed or partial discovery retains unconfirmed entries. Account changes remove the previous account's providers; credential refreshes for the same account preserve them. No credentials are stored in the inventory.+ ## Identity Remote sessions use separate logical and routing identities:src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts239 + / 69 −
@@ -13,13 +13,15 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';+import { isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, CloudSandboxEnabledSettingId,+ CloudSandboxAuthenticationRequiredError, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService,@@ -28,6 +30,7 @@ import { type ICloudSandboxCreateSessionRequest, type ICloudSandboxCreatedSession, type ICloudSandboxDiscoveryResult,+ type ICloudSandboxDiscoveredSession, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js';@@ -36,11 +39,12 @@ import { agentHostAuthority } from '../../../../../platform/agentHost/common/age import { findRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';-import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.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 { IStorageEntry, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../../../workbench/common/contributions.js';+import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { CloudSandboxReadOnlySessionHandler } from './cloudSandboxReadOnlySessionHandler.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js';@@ -55,6 +59,10 @@ import { createCloudSandboxConnectionCustomization, isCloudSandboxConnectionAddr import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; const LOG_PREFIX = '[CloudSandboxAgentHost]';+const DISCOVERY_STALE_AFTER_MS = 60_000;+const FULL_DISCOVERY_INTERVAL_MS = 15 * 60_000;+const MAX_DISCOVERY_RETRY_INTERVAL_MS = 5 * 60_000;+const INVENTORY_STORAGE_PREFIX = 'sessions.cloudSandbox.inventory.'; /** * Mission Control creates every sandbox session as `ahp-session:/<id>` while the host advertises the@@ -100,6 +108,19 @@ interface ICloudSandboxEnvironment { */ readonly taskId?: string; readonly name: string;+ readonly repoName?: string;+ readonly updatedAt?: string;+}++function isDiscoveredSandboxSession(value: unknown): value is ICloudSandboxDiscoveredSession {+ const candidate = value as Partial<ICloudSandboxDiscoveredSession> | undefined;+ return isObject(candidate)+ && typeof candidate.environmentId === 'string' && candidate.environmentId.length > 0+ && typeof candidate.sessionId === 'string' && candidate.sessionId.length > 0+ && typeof candidate.taskId === 'string' && candidate.taskId.length > 0+ && typeof candidate.name === 'string'+ && (candidate.repoName === undefined || typeof candidate.repoName === 'string')+ && (candidate.updatedAt === undefined || typeof candidate.updatedAt === 'string'); } /**@@ -131,6 +152,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo /** Provider instances keyed by connection address (`cloudsandbox:<envId>`). */ private readonly _providerInstances = new Map<string, CloudSandboxSessionsProvider>(); private readonly _providerStores = this._register(new DisposableMap<string>());+ private _persistedInventory = new Map<string, string>(); /** Environment metadata keyed by connection address, for on-demand reconnect. */ private readonly _environments = new Map<string, ICloudSandboxEnvironment>(); /** In-flight connects keyed by address, so concurrent opens share one attempt. */@@ -154,8 +176,12 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo /** Serializes discovery so overlapping triggers can't interleave reconciliation. */ private _discoveryInFlight: Promise<void> | undefined; private _discoveryQueued: Promise<void> | undefined;- /** Whether discovery has completed at least once, used to stop the auth-driven retry. */- private _hasDiscovered = false;+ private _discoveryToken: CancellationToken | undefined;+ private _discoveryIncremental = false;+ private _lastDiscoveryAttempt: number | undefined;+ private _lastFullDiscovery: number | undefined;+ private _discoveryRetryInterval = DISCOVERY_STALE_AFTER_MS;+ private _accountKey: string | undefined; /** * Keeps the "GitHub Sandboxes" filter entry present for as long as the feature is on, so the * place is visible and selectable before the user has any sandbox session to put in it.@@ -170,11 +196,12 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IConfigurationService private readonly _configurationService: IConfigurationService,- @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @INotificationService private readonly _notificationService: INotificationService, @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, @ILogService private readonly _logService: ILogService,+ @IHostService private readonly _hostService: IHostService,+ @IStorageService private readonly _storageService: IStorageService, ) { super(); @@ -220,19 +247,24 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo this._register(this._agentHostFilterService.registerDiscoveryHandler(() => this._discoverAndSeed())); void this._discoverAndSeed(); - // Discovery needs a GitHub session, and the auth provider is contributed by an extension that- // may not be registered yet at startup. Retry as sessions become available, until the first- // success; from then on the discovery handler above drives refreshes.- const retryUntilFirstSuccess = this._register(new DisposableStore());- const retry = () => {- if (this._hasDiscovered) {- retryUntilFirstSuccess.clear();+ this._register(this._hostService.onDidChangeFocus(focused => {+ if (focused) {+ void this._refreshIfStale();+ }+ }));+ this._register(this._agentHostFilterService.onDidChange(() => {+ if (this._agentHostFilterService.selectedHostId === CLOUD_SANDBOX_HOST_GROUP.id) {+ void this._refreshIfStale();+ }+ }));++ this._register(this._apiService.onDidChangeAccount(accountKey => {+ if (!this._isEnabled()) { return; }- void this._discoverAndSeed();- };- retryUntilFirstSuccess.add(this._authenticationService.onDidChangeSessions(retry));- retryUntilFirstSuccess.add(this._authenticationService.onDidRegisterAuthenticationProvider(retry));+ this._restoreAccount(accountKey);+ void this._discoverAndSeed(false, true);+ })); // Connect-on-open: resolves a seeded session by establishing the relay and waiting for the // host to advertise its agent. Scoped to our authorities so it never intercepts other@@ -249,96 +281,214 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo })); } - /**- * Discover environment-bound sandbox sessions and seed them into per-environment providers so- * they appear in the sessions list **without** connecting. Reconciles against the result:- * environments that have vanished from discovery (e.g. their task was archived) and are not- * currently connected are torn down, so stale providers/sessions don't linger. Best-effort:- * a failed discovery is logged and leaves existing state untouched.- *- * Runs are serialized, with at most one follow-up queued, so overlapping triggers can't- * interleave their reconciliation passes.- */- private _discoverAndSeed(): Promise<void> {+ protected async _refreshIfStale(): Promise<void> {+ if (this._discoveryInFlight) {+ await (this._discoveryQueued ?? this._discoveryInFlight);+ return;+ }+ if (!this._hostService.hasFocus || (this._lastDiscoveryAttempt !== undefined && Date.now() - this._lastDiscoveryAttempt < this._discoveryRetryInterval)) {+ return;+ }+ await this._discoverAndSeed(true);+ }++ /** Share overlapping scans, queuing at most one full scan when a stronger refresh is needed. */+ private _discoverAndSeed(incremental = false, retry = false): Promise<void> {+ if (!this._isEnabled() || this._store.isDisposed) {+ return Promise.resolve();+ } if (this._discoveryInFlight) {+ if (!retry && this._discoveryToken === this._enabledCts.token && (incremental || !this._discoveryIncremental)) {+ return this._discoveryQueued ?? this._discoveryInFlight;+ } this._discoveryQueued ??= this._discoveryInFlight.then(() => { this._discoveryQueued = undefined; return this._discoverAndSeed(); }); return this._discoveryQueued; }- this._discoveryInFlight = this._doDiscoverAndSeed().finally(() => {+ this._lastDiscoveryAttempt = Date.now();+ this._discoveryIncremental = incremental && this._lastFullDiscovery !== undefined+ && this._lastDiscoveryAttempt - this._lastFullDiscovery < FULL_DISCOVERY_INTERVAL_MS;+ this._discoveryToken = this._enabledCts.token;+ this._discoveryInFlight = this._doDiscoverAndSeed(this._discoveryToken, this._discoveryIncremental).finally(() => { this._discoveryInFlight = undefined;+ this._discoveryToken = undefined; }); return this._discoveryInFlight; } - private async _doDiscoverAndSeed(): Promise<void> {- if (!this._isEnabled()) {- return;- }- const token = this._enabledCts.token;+ private async _doDiscoverAndSeed(token: CancellationToken, incremental: boolean): Promise<void> { let result: ICloudSandboxDiscoveryResult; try {- result = await this._apiService.listSessions(token);+ const accountKey = await this._apiService.getAccountKey();+ if (token.isCancellationRequested || !this._isEnabled()) {+ return;+ }+ if (this._restoreAccount(accountKey)) {+ token = this._enabledCts.token;+ this._discoveryToken = token;+ this._discoveryIncremental = incremental = false;+ this._lastDiscoveryAttempt = Date.now();+ }+ if (!accountKey) {+ throw new CloudSandboxAuthenticationRequiredError();+ }+ result = await this._apiService.listSessions(token, { incremental }); } catch (error) { if (token.isCancellationRequested || isCancellationError(error) || !this._isEnabled()) { return; } result = { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; }+ if (token.isCancellationRequested || !this._isEnabled()) {+ return;+ }+ this._discoveryRetryInterval = result.kind === 'failed' || result.kind === 'partial'+ ? Math.min(this._discoveryRetryInterval * 2, MAX_DISCOVERY_RETRY_INTERVAL_MS)+ : DISCOVERY_STALE_AFTER_MS; if (result.kind === 'failed') {- // Not "no sessions" — leave existing state alone, and stay eligible for the auth retry. this._logService.warn(`${LOG_PREFIX} Discovery failed: ${result.reason}`); return; }- // The feature may have been disabled while the scan was in flight.- if (token.isCancellationRequested || !this._isEnabled()) {- return;+ if (result.kind === 'complete') {+ this._lastFullDiscovery = Date.now(); }- this._hasDiscovered = true; const present = new Set<string>();+ const updatedTasks = new Set<string>(); for (const session of result.sessions) { if (!session.environmentId || !session.sessionId) { continue; } const address = cloudSandboxAddress(session.environmentId); present.add(address);- this._ensureProvider({ environmentId: session.environmentId, sessionId: session.sessionId, taskId: session.taskId, name: session.name });- const provider = this._providerInstances.get(address);- const parsed = session.updatedAt ? Date.parse(session.updatedAt) : Number.NaN;- const modifiedTime = Number.isNaN(parsed) ? Date.now() : parsed;- const project = discoveredSessionProject(session.repoName);- const meta: IAgentSessionMetadata = {- // Seed under the agent-provider (UI) scheme, preserving the session id: the host- // lists the same id back, so this reconciles with `listSessions()` on connect.- session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, session.sessionId),- startTime: modifiedTime,- modifiedTime,- summary: session.name,- ...(project ? { project } : {}),- };- provider?.seedSessions([meta]);+ updatedTasks.add(session.taskId);+ this._seedDiscoveredSession(session); } - // Negative reconciliation: drop environments that are no longer discoverable and aren't- // currently connected (an open/connected session is kept so active use isn't disrupted).- // Only a complete scan is authoritative — a partial one is missing entries that still exist.- if (result.kind === 'complete') {- for (const address of [...this._environments.keys()]) {- if (present.has(address) || this._provisioning.has(address)) {- continue;+ const removedTasks = new Set(result.kind === 'complete' ? [] : result.removedTaskIds);+ for (const [address, environment] of this._environments) {+ if (present.has(address) || this._provisioning.has(address)) {+ continue;+ }+ if (result.kind !== 'complete' && (!environment.taskId || (!removedTasks.has(environment.taskId) && !updatedTasks.has(environment.taskId)))) {+ continue;+ }+ const connected = this._remoteAgentHostService.connections.some(+ c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status));+ if (!connected) {+ this._teardownEnvironment(address);+ }+ }++ this._persistInventory();+ this._logService.info(`${LOG_PREFIX} Seeded ${present.size} discovered sandbox environment(s)${result.kind === 'partial' ? ' (partial scan; kept existing entries)' : ''}.`);+ }++ private _seedDiscoveredSession(session: ICloudSandboxDiscoveredSession): void {+ this._ensureProvider(session);+ const address = cloudSandboxAddress(session.environmentId);+ this._environments.set(address, session);+ const provider = this._providerInstances.get(address);+ provider?.setLabel(session.name);+ const parsed = session.updatedAt ? Date.parse(session.updatedAt) : Number.NaN;+ const modifiedTime = Number.isNaN(parsed) ? provider?.getCachedSession(session.sessionId)?.updatedAt.get().getTime() ?? Date.now() : parsed;+ const project = discoveredSessionProject(session.repoName);+ provider?.seedSessions([{+ session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, session.sessionId),+ startTime: modifiedTime,+ modifiedTime,+ summary: session.name,+ ...(project ? { project } : {}),+ }], { updateExisting: true });+ }++ private _restoreAccount(accountKey: string | undefined): boolean {+ if (accountKey === this._accountKey) {+ return false;+ }+ this._teardownAll();+ this._accountKey = accountKey;+ if (accountKey) {+ const storageKey = INVENTORY_STORAGE_PREFIX + accountKey;+ const keys = this._storageService.keys(StorageScope.PROFILE, StorageTarget.MACHINE).filter(key => key.startsWith(`${storageKey}.`));+ let restored = 0;+ for (const key of keys) {+ const sessions = this._readInventory(key);+ this._persistedInventory.set(key, JSON.stringify({ version: 1, sessions }));+ for (const session of sessions) {+ this._seedDiscoveredSession(session);+ restored++; }- const connected = this._remoteAgentHostService.connections.some(- c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status));- if (!connected) {- this._teardownEnvironment(address);+ }+ if (keys.length === 0 && this._storageService.get(storageKey, StorageScope.PROFILE) !== undefined) {+ for (const session of this._readInventory(storageKey)) {+ this._seedDiscoveredSession(session);+ restored++; }+ this._persistInventory();+ }+ if (restored) {+ this._logService.info(`${LOG_PREFIX} Restored ${restored} cached sandbox environment(s).`); } }+ return true;+ } - this._logService.info(`${LOG_PREFIX} Seeded ${present.size} discovered sandbox environment(s)${result.kind === 'partial' ? ' (partial scan; kept existing entries)' : ''}.`);+ private _readInventory(storageKey: string): readonly ICloudSandboxDiscoveredSession[] {+ let cached: { readonly version?: number; readonly sessions?: unknown } | undefined;+ try {+ cached = this._storageService.getObject(storageKey, StorageScope.PROFILE);+ } catch (error) {+ this._logService.warn(`${LOG_PREFIX} Reading cached sandbox inventory failed.`, error);+ return [];+ }+ if (cached !== undefined) {+ if (isObject(cached) && cached.version === 1 && Array.isArray(cached.sessions) && cached.sessions.every(isDiscoveredSandboxSession)) {+ return cached.sessions;+ }+ this._logService.warn(`${LOG_PREFIX} Ignoring invalid cached sandbox inventory.`);+ }+ return [];+ }++ private _persistInventory(): void {+ if (!this._accountKey) {+ return;+ }+ const storageKey = INVENTORY_STORAGE_PREFIX + this._accountKey;+ const inventory = new Map<string, string>();+ const entries: IStorageEntry[] = [];+ for (const environment of this._environments.values()) {+ if (environment.sessionId && environment.taskId) {+ const session: ICloudSandboxDiscoveredSession = {+ environmentId: environment.environmentId,+ sessionId: environment.sessionId,+ taskId: environment.taskId,+ name: environment.name,+ repoName: environment.repoName,+ updatedAt: environment.updatedAt,+ };+ const key = `${storageKey}.${JSON.stringify([session.environmentId, session.sessionId])}`;+ const value = JSON.stringify({ version: 1, sessions: [session] });+ inventory.set(key, value);+ if (this._persistedInventory.get(key) !== value) {+ entries.push({ key, value, scope: StorageScope.PROFILE, target: StorageTarget.MACHINE });+ }+ }+ }+ // Only remove this window's known entries, never a concurrent window's newly stored sessions.+ for (const key of this._persistedInventory.keys()) {+ if (!inventory.has(key)) {+ entries.push({ key, value: undefined, scope: StorageScope.PROFILE, target: StorageTarget.MACHINE });+ }+ }+ if (this._storageService.get(storageKey, StorageScope.PROFILE) !== undefined) {+ entries.push({ key: storageKey, value: undefined, scope: StorageScope.PROFILE, target: StorageTarget.MACHINE });+ }+ this._persistedInventory = inventory;+ this._storageService.storeAll(entries, false); } /**@@ -366,24 +516,33 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo if (!this._isEnabled()) { throw new Error('Copilot cloud sandbox connections are not enabled.'); }+ const accountKey = await this._apiService.getAccountKey();+ if (!this._isEnabled() || token.isCancellationRequested) {+ throw new CancellationError();+ }+ if (!accountKey) {+ throw new CloudSandboxAuthenticationRequiredError();+ }+ this._restoreAccount(accountKey);+ const enabledToken = this._enabledCts.token; const created = await this._apiService.createSession(request, token); const name = request.repoNwo ?? created.taskId; const address = cloudSandboxAddress(created.environmentId); // `_teardownAll` has already snapshotted the environments it knows about, so registering a // provider now would leave one behind that nothing reconciles.- if (!this._isEnabled() || token.isCancellationRequested) {+ if (!this._isEnabled() || token.isCancellationRequested || enabledToken.isCancellationRequested) { throw new CancellationError(); } this._provisioning.add(address); let seededProvider: CloudSandboxSessionsProvider | undefined; try {- this._ensureProvider({ environmentId: created.environmentId, sessionId: created.sessionId, taskId: created.taskId, name });+ const now = Date.now();+ this._ensureProvider({ ...created, name, repoName: request.repoNwo, updatedAt: new Date(now).toISOString() }); const provider = this._providerInstances.get(address); if (!provider) { throw new Error(`No sessions provider was registered for sandbox environment ${created.environmentId}`); }- const now = Date.now(); const project = discoveredSessionProject(request.repoNwo); provider.seedProvisionalSession({ // Same identity discovery seeds under: Mission Control issues the session as@@ -395,6 +554,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo ...(project ? { project } : {}), }); seededProvider = provider;+ this._persistInventory(); await this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name }); @@ -445,6 +605,11 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo this._enabledCts.cancel(); this._enabledCts.dispose(); this._enabledCts = new CancellationTokenSource();+ this._lastDiscoveryAttempt = undefined;+ this._lastFullDiscovery = undefined;+ this._discoveryRetryInterval = DISCOVERY_STALE_AFTER_MS;+ this._accountKey = undefined;+ this._persistedInventory.clear(); for (const address of [...this._environments.keys()]) { this._teardownEnvironment(address); }@@ -687,7 +852,12 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo // `connect()` reaches here with only the fields its caller had, so preserve anything // discovery already resolved — notably the task id that makes history readable offline. const known = this._environments.get(address);- this._environments.set(address, { ...known, ...env, taskId: env.taskId ?? known?.taskId });+ this._environments.set(address, {+ ...known, ...env,+ taskId: env.taskId ?? known?.taskId,+ repoName: env.repoName ?? known?.repoName,+ updatedAt: env.updatedAt ?? known?.updatedAt,+ }); if (this._providerStores.has(address)) { return; }src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxApiService.ts208 + / 76 −
@@ -7,6 +7,7 @@ import { Limiter, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js';+import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { CLOUD_SANDBOX_AGENT_SLUG,@@ -56,6 +57,13 @@ interface ITaskDetail extends ITaskSummary { readonly sessions?: readonly { readonly id: string; readonly environment_id?: string }[]; } +interface ICachedSandboxTask {+ readonly summary: ITaskSummary;+ readonly session?: ICloudSandboxDiscoveredSession;+ readonly repositoryId?: number;+ readonly needsRefresh?: boolean;+}+ const LOG_PREFIX = '[CloudSandboxApi]'; /**@@ -102,6 +110,8 @@ const DISCOVERY_TASK_PAGE_LIMIT = 10; */ const DISCOVERY_TASK_FETCH_CONCURRENCY = 5; +const DISCOVERY_OVERLAP_MS = 60_000;+ /** HTTP status GitHub answers a rate-limited request with. */ const HTTP_TOO_MANY_REQUESTS = 429; @@ -144,6 +154,11 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA /** Resolved (or in-flight) repository names, keyed by numeric repository id. */ private readonly _repositoryNames = new Map<number, Promise<string | undefined>>();+ private readonly _discoveredTasks = new Map<string, ICachedSandboxTask>();+ private _discoverySince: string | undefined;+ private _discoveryGeneration = 0;+ private readonly _onDidChangeAccount = this._register(new Emitter<string | undefined>());+ readonly onDidChangeAccount = this._onDidChangeAccount.event; constructor( @IRequestService private readonly _requestService: IRequestService,@@ -153,6 +168,51 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA @ICloudSandboxTelemetryService private readonly _telemetry: ICloudSandboxTelemetryService, ) { super();+ this._register(this._authenticationService.onDidChangeSessions(e => {+ if (e.providerId === this._authenticationProviderId) {+ void this._onAuthenticationChanged();+ }+ }));+ this._register(this._authenticationService.onDidRegisterAuthenticationProvider(e => {+ if (e.id === this._authenticationProviderId) {+ void this._onAuthenticationChanged();+ }+ }));+ this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(e => {+ if (e.id === this._authenticationProviderId) {+ void this._onAuthenticationChanged();+ }+ }));+ }++ private get _authenticationProviderId(): string {+ return this._productService.defaultChatAgent?.provider?.default?.id ?? 'github';+ }++ async getAccountKey(): Promise<string | undefined> {+ const generation = this._discoveryGeneration;+ const session = await this._resolveGitHubSession();+ if (generation !== this._discoveryGeneration) {+ throw new CancellationError();+ }+ return session ? JSON.stringify([this._authenticationProviderId, session.account.id]) : undefined;+ }++ private async _onAuthenticationChanged(): Promise<void> {+ const generation = ++this._discoveryGeneration;+ this._discoverySince = undefined;+ this._discoveredTasks.clear();+ this._repositoryNames.clear();+ try {+ const accountKey = await this.getAccountKey();+ if (generation === this._discoveryGeneration && !this._store.isDisposed) {+ this._onDidChangeAccount.fire(accountKey);+ }+ } catch (error) {+ if (!isCancellationError(error)) {+ this._logService.warn(`${LOG_PREFIX} Resolving discovery account failed: ${toErrorMessage(error)}`);+ }+ } } async connect(request: ICloudSandboxConnectionRequest, token: CancellationToken): Promise<CloudSandboxConnectResult> {@@ -183,91 +243,135 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA return environment; } - /**- * Enumerate sandbox-backed cloud sessions by scanning recent tasks and resolving each one's- * Mission Control environment binding.- *- * Only a `complete` result may be reconciled against: a partial or truncated scan is missing- * entries that still exist.- */- async listSessions(token: CancellationToken): Promise<ICloudSandboxDiscoveryResult> {- const tasks: ITaskSummary[] = [];+ /** Incremental scans preserve absent tasks; full scans also find tasks without environment-kind metadata. */+ async listSessions(token: CancellationToken, options?: { readonly incremental?: boolean }): Promise<ICloudSandboxDiscoveryResult> {+ const generation = this._discoveryGeneration;+ const since = options?.incremental ? this._discoverySince : undefined;+ const tasks = new Map<string, ITaskSummary>();+ const cache = new Map(this._discoveredTasks); let truncated = false;- for (let page = 1; page <= DISCOVERY_TASK_PAGE_LIMIT; page++) {- let batch: readonly ITaskSummary[];- let hasNextPage: boolean;- try {- const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks?per_page=${DISCOVERY_TASK_SCAN_LIMIT}&page=${page}`, 'list', token);- const response = await this._readJson<{ tasks?: readonly ITaskSummary[] }>(context);- if (!response?.tasks) {- // Earlier pages are still worth seeding, so only fail outright on the first.- if (page === 1) {- return { kind: 'failed', reason: `listTasks returned no 'tasks' array` };- }- truncated = true;- break;- }- batch = response.tasks;- hasNextPage = hasNextLink(context.res.headers?.['link']);- } catch (error) {- if (isCancellationError(error)) {- throw error;- }+ let checkpoint: number | undefined;+ let latestUpdate: number | undefined;+ // Separate repository scopes include workspace-less sandboxes as well as repository sessions.+ for (const withRepository of [true, false]) {+ for (let page = 1; page <= DISCOVERY_TASK_PAGE_LIMIT; page++) { if (token.isCancellationRequested) { throw new CancellationError(); }- if (page === 1) {- return { kind: 'failed', reason: `listTasks failed: ${toErrorMessage(error)}` };+ const query: Record<string, string> = {+ per_page: String(DISCOVERY_TASK_SCAN_LIMIT),+ page: String(page),+ sort: 'updated_at',+ direction: 'desc',+ with_repo: String(withRepository),+ ...(since ? { since, include_environment_kinds: 'managed-sandbox' } : {}),+ };+ try {+ const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks${toQuery(query)}`, 'list', token);+ const response = await this._readJson<{ tasks?: readonly ITaskSummary[] }>(context);+ if (!Array.isArray(response?.tasks)) {+ throw new Error('listTasks returned no tasks array');+ }+ const date = context.res.headers?.['date'];+ const serverTime = typeof date === 'string' ? Date.parse(date) : Number.NaN;+ if (!Number.isNaN(serverTime)) {+ checkpoint = Math.min(checkpoint ?? serverTime, serverTime);+ }+ for (const task of response.tasks) {+ tasks.set(task.id, task);+ const updatedAt = task.updated_at ? Date.parse(task.updated_at) : Number.NaN;+ if (!Number.isNaN(updatedAt)) {+ latestUpdate = Math.max(latestUpdate ?? updatedAt, updatedAt);+ }+ }+ if (!hasNextLink(context.res.headers?.['link'])) {+ break;+ }+ if (page === DISCOVERY_TASK_PAGE_LIMIT) {+ truncated = true;+ }+ } catch (error) {+ if (isCancellationError(error) || token.isCancellationRequested) {+ throw new CancellationError();+ }+ if (tasks.size === 0) {+ return { kind: 'failed', reason: `listTasks failed: ${toErrorMessage(error)}` };+ }+ this._logService.warn(`${LOG_PREFIX} Discovery page ${page} (with_repo=${withRepository}) failed: ${toErrorMessage(error)}`);+ truncated = true;+ break; }- this._logService.warn(`${LOG_PREFIX} Discovery page ${page} failed: ${toErrorMessage(error)}`);- truncated = true;- break;- }- tasks.push(...batch);- if (!hasNextPage) {- break; }- if (page === DISCOVERY_TASK_PAGE_LIMIT) {- truncated = true;+ }++ const scannedTaskIds = new Set(tasks.keys());+ if (since) {+ for (const [id, cached] of cache) {+ if (!tasks.has(id) && (cached.needsRefresh || !cached.session || (cached.repositoryId !== undefined && !cached.session.repoName))) {+ tasks.set(id, cached.summary);+ } }- if (token.isCancellationRequested) {- truncated = true;- break;+ }+ const removedTaskIds: string[] = [];+ const sandboxTasks: ITaskSummary[] = [];+ for (const task of tasks.values()) {+ if (!task.archived_at && isCloudSandboxTask(task)) {+ sandboxTasks.push(task);+ } else {+ if (cache.has(task.id) || isCloudSandboxTask(task)) {+ removedTaskIds.push(task.id);+ }+ cache.delete(task.id); } }-- const sandboxTasks = tasks.filter(task => !task.archived_at && isCloudSandboxTask(task)); let unresolved = 0;- // Bounded fan-out: resolving every task at once trips the rate limit, and each rejected- // fetch silently drops its session from this pass. const limiter = new Limiter<ICloudSandboxDiscoveredSession | undefined>(DISCOVERY_TASK_FETCH_CONCURRENCY); let discovered: (ICloudSandboxDiscoveredSession | undefined)[]; try { discovered = await Promise.all(sandboxTasks.map(task => limiter.queue(async (): Promise<ICloudSandboxDiscoveredSession | undefined> => { try {- const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(task.id)}`, 'get', token);- const full = await this._readJson<ITaskDetail>(context);- if (!full) {- unresolved++;- return undefined;+ if (token.isCancellationRequested) {+ throw new CancellationError(); }- const binding = getTaskEnvironmentBinding(full);- if (!binding) {- // No environment bound yet — a real state, not a failure to resolve.- return undefined;+ let cached = cache.get(task.id);+ if (cached?.needsRefresh || !cached?.session || !task.updated_at || task.updated_at !== cached.summary.updated_at) {+ const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(task.id)}`, 'get', token);+ const full = await this._readJson<ITaskDetail>(context);+ if (!full) {+ throw new Error('getTask returned no task');+ }+ if (full.archived_at) {+ removedTaskIds.push(task.id);+ cache.delete(task.id);+ return undefined;+ }+ const binding = getTaskEnvironmentBinding(full);+ if (!binding && cached?.session) {+ removedTaskIds.push(task.id);+ }+ cached = {+ summary: task,+ repositoryId: full.repository?.id ?? task.repository?.id,+ session: binding ? {+ ...binding,+ taskId: task.id,+ name: full.name ?? task.name ?? `Sandbox ${task.id}`,+ updatedAt: full.updated_at ?? task.updated_at,+ } : undefined,+ }; }- const repositoryId = full.repository?.id ?? task.repository?.id;- const repoName = repositoryId !== undefined ? await this._resolveRepositoryName(repositoryId, token) : undefined;- return {- environmentId: binding.environmentId,- sessionId: binding.sessionId,- taskId: task.id,- name: full.name ?? task.name ?? `Sandbox ${task.id}`,- repoName,- updatedAt: full.updated_at ?? task.updated_at,- };+ if (cached.session && cached.repositoryId !== undefined && !cached.session.repoName) {+ const repoName = await this._resolveRepositoryName(cached.repositoryId, token);+ cached = { ...cached, session: { ...cached.session, repoName } };+ }+ cache.set(task.id, cached);+ return cached.session; } catch (error) {+ if (isCancellationError(error) || token.isCancellationRequested) {+ throw new CancellationError();+ } this._logService.warn(`${LOG_PREFIX} Discovery getTask ${task.id} failed: ${toErrorMessage(error)}`);+ cache.set(task.id, { ...cache.get(task.id), summary: task, needsRefresh: true }); unresolved++; return undefined; }@@ -276,10 +380,38 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA limiter.dispose(); } + if (token.isCancellationRequested) {+ throw new CancellationError();+ }+ if (generation !== this._discoveryGeneration) {+ return { kind: 'failed', reason: 'Authentication changed during discovery' };+ }+ const partial = unresolved > 0 || truncated;+ if (!partial) {+ // Browsers may not expose Date through CORS; task timestamps also use the server's clock.+ const nextCheckpoint = checkpoint ?? latestUpdate;+ if (nextCheckpoint !== undefined) {+ this._discoverySince = new Date(nextCheckpoint - DISCOVERY_OVERLAP_MS).toISOString();+ }+ if (!since) {+ for (const id of cache.keys()) {+ if (!scannedTaskIds.has(id)) {+ cache.delete(id);+ }+ }+ }+ }+ this._discoveredTasks.clear();+ for (const [id, cached] of cache) {+ this._discoveredTasks.set(id, cached);+ } const sessions = discovered.filter((session): session is ICloudSandboxDiscoveredSession => session !== undefined); const unnamed = sessions.filter(session => !session.repoName).length;- this._logService.info(`${LOG_PREFIX} Discovery found ${sessions.length} sandbox session(s) from ${sandboxTasks.length} sandbox task(s) out of ${tasks.length} scanned${truncated ? ' (scan truncated)' : ''}${unresolved > 0 ? `; ${unresolved} unresolved` : ''}${unnamed > 0 ? `; ${unnamed} without a repository name (they group under "Unknown")` : ''}.`);- return { kind: unresolved > 0 || truncated ? 'partial' : 'complete', sessions };+ this._logService.info(`${LOG_PREFIX} ${since ? 'Incremental discovery' : 'Discovery'} found ${sessions.length} sandbox session(s) from ${sandboxTasks.length} sandbox task(s) out of ${scannedTaskIds.size} scanned${truncated ? ' (scan truncated)' : ''}${unresolved > 0 ? `; ${unresolved} unresolved` : ''}${unnamed > 0 ? `; ${unnamed} without a repository name (they group under "Unknown")` : ''}.`);+ if (partial) {+ return { kind: 'partial', sessions, removedTaskIds };+ }+ return since ? { kind: 'incremental', sessions, removedTaskIds } : { kind: 'complete', sessions }; } /**@@ -502,7 +634,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA } private async _request(url: string, callSite: string, action: CloudSandboxRequestAction, headers: Record<string, string>, token: CancellationToken, timeoutMs: number = REQUEST_TIMEOUT_MS, body?: unknown, method?: 'GET' | 'POST' | 'DELETE'): Promise<IRequestContext> {- const accessToken = await this._resolveGitHubToken();+ const accessToken = (await this._resolveGitHubSession())?.accessToken; if (!accessToken) { // No request is issued, so there is no request outcome to count. throw new CloudSandboxAuthenticationRequiredError();@@ -614,8 +746,8 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA } /** A GitHub session carrying at least the configured chat provider scopes. */- private async _resolveGitHubToken(): Promise<string | undefined> {- const providerId = this._productService.defaultChatAgent?.provider?.default?.id ?? 'github';+ private async _resolveGitHubSession(): Promise<AuthenticationSession | undefined> {+ const providerId = this._authenticationProviderId; const scopes = this._productService.defaultChatAgent?.providerScopes?.[0] ?? FALLBACK_SCOPES; let exact: readonly AuthenticationSession[];@@ -624,29 +756,29 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA } catch (error) { // Throws when the auth provider extension has not registered yet. this._logService.warn(`${LOG_PREFIX} getSessions('${providerId}') failed: ${toErrorMessage(error)}`);- return undefined;+ throw new CloudSandboxAuthenticationRequiredError(); } if (exact.length > 0) {- return exact[0].accessToken;+ return exact[0]; } // Fall back to the narrowest session whose scopes are a superset of what we need. const all = await this._authenticationService.getSessions(providerId, undefined, undefined, true); const required = new Set(scopes);- let best: { token: string; extra: number } | undefined;+ let best: { session: AuthenticationSession; extra: number } | undefined; for (const session of all) { const granted = new Set(session.scopes); if ([...required].every(scope => granted.has(scope))) { const extra = granted.size - required.size; if (!best || extra < best.extra) {- best = { token: session.accessToken, extra };+ best = { session, extra }; } } } if (!best) { this._logService.warn(`${LOG_PREFIX} No '${providerId}' session with scopes [${scopes.join(', ')}]`); }- return best?.token;+ return best?.session; } } src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts3 + / 1 −
@@ -68,7 +68,9 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide if (this._sessionCache.has(rawId)) { return; }- this._sessionCache.set(rawId, this.createAdapter(meta));+ const adapter = this.createAdapter(meta);+ adapter.updateDiscoveryMetadata(meta);+ this._sessionCache.set(rawId, adapter); this._withheldSessions.add(rawId); // No deadline yet: the clock starts when the host first omits it. this._provisionalSessions.set(rawId, undefined);src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts28 + / 19 −
@@ -138,12 +138,14 @@ export interface IRemoteAgentHostSessionsProviderConfig { export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessionsProvider { readonly id: string;- readonly label: string;+ private _label: string;+ get label(): string { return this._label; } readonly icon: ThemeIcon = Codicon.remote; readonly remoteAddress: string; readonly remoteLocationPreferenceKey: string; readonly hostGroup: IAgentHostGroup | undefined;- readonly browseActions: readonly ISessionWorkspaceBrowseAction[];+ private _browseActions: readonly ISessionWorkspaceBrowseAction[];+ get browseActions(): readonly ISessionWorkspaceBrowseAction[] { return this._browseActions; } readonly canConnectOnDemand: boolean; readonly onDidReportConnectProgress: Event<IAgentHostConnectProgress> | undefined; readonly showConnectionLog?: () => Promise<void>;@@ -274,7 +276,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio const displayName = config.name || config.address; this.id = `agenthost-${this._connectionAuthority}`;- this.label = displayName;+ this._label = displayName; this.remoteAddress = config.address; this.remoteLocationPreferenceKey = config.preferenceKey ?? config.address; this.hostGroup = config.hostGroup;@@ -292,7 +294,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio })); this.automations = this._automationStore; - this.browseActions = [{+ this._browseActions = [{ label: localize('folders', "Folders"), description: displayName, group: SESSION_WORKSPACE_GROUP_REMOTE,@@ -454,7 +456,6 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio } protected _adapterOptions() {- const hostLabel = this._workspaceHostLabel; const typeIcon = this._workspaceTypeIcon; return { readOnly: this._readOnly,@@ -464,7 +465,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio const uriForDescription = project?.uri ?? primary; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, primary ?? project?.uri);- return RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectories, hostLabel, gitHubInfo, gitState, description, branchProtectionPatterns, typeIcon);+ return RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectories, this._workspaceHostLabel, gitHubInfo, gitState, description, branchProtectionPatterns, typeIcon); }, }; }@@ -566,31 +567,39 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio this._connectionStatus.set(status, undefined); } - /**- * Seed discovered session summaries into the cache so they surface in the sessions list- * **before** a connection is established (lazy discovery).- *- * An entry that already exists keeps everything the host has told us, except for a missing- * project: the repository name is resolved over the network and that lookup can fail, so- * filling it in on a later pass is what makes retrying worth anything. Opening a seeded session- * triggers `connectOnDemand`, after which `_refreshSessions` reconciles against the host.- */- seedSessions(metas: readonly IAgentSessionMetadata[]): void {+ /** Refresh the provider's display name and notify picker consumers. */+ setLabel(name: string): void {+ const label = name || this.remoteAddress;+ if (this._label === label) {+ return;+ }+ this._label = label;+ this._browseActions = this._browseActions.map(action => ({ ...action, description: label }));+ this._refreshSessionWorkspaces();+ this._onDidChangeSessionTypes.fire();+ }++ /** Seed offline rows, optionally refreshing discovery-owned title, timestamp and project fields. */+ seedSessions(metas: readonly IAgentSessionMetadata[], options?: { readonly updateExisting?: boolean }): void { const added: ISession[] = []; const changed: ISession[] = []; for (const rawMeta of metas) { const meta = this._adoptSessionMeta(rawMeta); const rawId = AgentSession.id(meta.session); const existing = this._sessionCache.get(rawId); if (existing) {- // Announcing the change also marks the session cache dirty, so the filled-in- // project reaches the next persisted snapshot.- if (meta.project && !existing.project && existing.backfillProject(meta.project)) {+ const didChange = options?.updateExisting+ ? existing.updateDiscoveryMetadata(meta)+ : existing.backfillProject(meta.project);+ if (didChange) { changed.push(existing); } continue; } const adapter = this.createAdapter(meta);+ if (options?.updateExisting) {+ adapter.updateDiscoveryMetadata(meta);+ } this._sessionCache.set(rawId, adapter); added.push(adapter); }src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts668 + / 18 −
@@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert';-import { DeferredPromise } from '../../../../../../base/common/async.js';+import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../base/common/errors.js';-import { Event } from '../../../../../../base/common/event.js';+import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';+import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js'; import { URI } from '../../../../../../base/common/uri.js';+import { StorageValue } from '../../../../../../base/parts/storage/common/storage.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agent.js'; import { IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js';@@ -32,13 +34,14 @@ import { type ICloudSandboxEnvironment as ICloudSandboxEnvironmentRecord, } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js';-import { IObservable, observableValue } from '../../../../../../base/common/observable.js';+import { constObservable, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js';-import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js';+import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';+import { IHostService } from '../../../../../../workbench/services/host/browser/host.js'; import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAgentHostGroup } from '../../../../../common/agentHostSessionsProvider.js'; import { IAgentHostFilterService } from '../../../../../services/agentHostFilter/common/agentHostFilter.js';@@ -61,21 +64,27 @@ class StubProvider extends mock<CloudSandboxSessionsProvider>() { disposed = false; override readonly id: string;+ private _displayLabel: string;+ override get label(): string { return this._displayLabel; } constructor(readonly config: IRemoteAgentHostSessionsProviderConfig) { super(); this.id = `agenthost-${config.address}`;+ this._displayLabel = config.name; } - /**- * Records seeds, de-duplicating by session id. Unlike the real provider this does not model- * the project backfill on an already-seeded session — that path is covered against the real- * provider in `remoteAgentHostSessionsProvider.test.ts`.- */- override seedSessions(metas: readonly IAgentSessionMetadata[]): void {+ override setLabel(label: string): void {+ this._displayLabel = label;+ }++ /** Records opt-in metadata updates; host-state merging is covered by the real provider's tests. */+ override seedSessions(metas: readonly IAgentSessionMetadata[], options?: { readonly updateExisting?: boolean }): void { for (const meta of metas) {- if (!this.seeded.some(seen => seen.session.toString() === meta.session.toString())) {+ const index = this.seeded.findIndex(seen => seen.session.toString() === meta.session.toString());+ if (index === -1) { this.seeded.push(meta);+ } else if (options?.updateExisting) {+ this.seeded[index] = meta; } } }@@ -108,6 +117,7 @@ class StubProvider extends mock<CloudSandboxSessionsProvider>() { private _toSession(meta: IAgentSessionMetadata): ISession { return upcastPartial<ISession>({ resource: URI.from({ scheme: 'agent-host-copilot', path: `/${AgentSession.id(meta.session)}` }),+ updatedAt: constObservable(new Date(meta.modifiedTime)), }); } @@ -133,6 +143,10 @@ class TestCloudSandboxContribution extends CloudSandboxAgentHostContribution { return this._waitForActivation(sessionType); } + checkForUpdates(): Promise<void> {+ return this._refreshIfStale();+ }+ protected override _instantiateProvider(config: IRemoteAgentHostSessionsProviderConfig): CloudSandboxSessionsProvider { const stub = new StubProvider(config); this.stubProviders.set(config.address, stub);@@ -184,6 +198,10 @@ interface ITestHarness { readonly historyRequests: string[]; /** Host groups currently declared to the filter service. */ readonly hostGroups: IAgentHostGroup[];+ readonly discoveryModes: boolean[];+ setFocused(focused: boolean): void;+ selectSandboxHost(): void;+ changeAccount(accountKey: string | undefined): void; } /**@@ -194,11 +212,14 @@ interface ITestHarness { async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: readonly ICloudSandboxDiscoveredSession[], options?: { /** Task Mission Control returns from `createSession`, or a rejection. */ readonly createSession?: () => Promise<ICloudSandboxCreatedSession>;- readonly listSessions?: (token: CancellationToken) => Promise<ICloudSandboxDiscoveryResult>;+ readonly listSessions?: (token: CancellationToken, options?: { readonly incremental?: boolean }) => Promise<ICloudSandboxDiscoveryResult>; readonly getEnvironment?: (id: string, token: CancellationToken) => Promise<ICloudSandboxEnvironmentRecord>; /** Whether the sandbox feature settings start on. Defaults to `true`. */ readonly enabled?: boolean; readonly logService?: ILogService;+ readonly storageService?: IStorageService;+ readonly accountKey?: string | null;+ readonly waitForDiscovery?: boolean; }): Promise<ITestHarness> { const discoveryHandlers: (() => Promise<void>)[] = []; const hostGroups: IAgentHostGroup[] = [];@@ -207,6 +228,13 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: const created: ICloudSandboxCreateSessionRequest[] = []; const connectedTo: string[] = []; const historyRequests: string[] = [];+ const discoveryModes: boolean[] = [];+ const focusChanges = store.add(new Emitter<boolean>());+ const hostSelectionChanges = store.add(new Emitter<void>());+ const accountChanges = store.add(new Emitter<string | undefined>());+ let accountKey = options?.accountKey === null ? undefined : options?.accountKey ?? '["github","account-1"]';+ let focused = true;+ let selectedHostId: string | undefined; const harness: ITestHarness = { discovered: sessions, environmentStatus: 'offline',@@ -215,6 +243,19 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: connectedTo, historyRequests, hostGroups,+ discoveryModes,+ changeAccount: value => {+ accountKey = value;+ accountChanges.fire(value);+ },+ setFocused: value => {+ focused = value;+ focusChanges.fire(value);+ },+ selectSandboxHost: () => {+ selectedHostId = GITHUB_SANDBOX_GROUP.id;+ hostSelectionChanges.fire();+ }, setEnabled: async (enabled: boolean) => { await configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, enabled); configurationService.onDidChangeConfigurationEmitter.fire({@@ -232,9 +273,12 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: } as ITestHarness; instantiationService.stub(ICloudSandboxApiService, new class extends mock<ICloudSandboxApiService>() {- override async listSessions(token: CancellationToken): Promise<ICloudSandboxDiscoveryResult> {+ override readonly onDidChangeAccount = accountChanges.event;+ override async getAccountKey(): Promise<string | undefined> { return accountKey; }+ override async listSessions(token: CancellationToken, discoveryOptions?: { readonly incremental?: boolean }): Promise<ICloudSandboxDiscoveryResult> {+ discoveryModes.push(discoveryOptions?.incremental === true); if (options?.listSessions) {- return options.listSessions(token);+ return options.listSessions(token, discoveryOptions); } return { kind: 'complete', sessions: harness.discovered }; }@@ -275,6 +319,8 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: }()); instantiationService.stub(ISessionsProvidersService, store.add(new StubSessionsProvidersService()) as unknown as ISessionsProvidersService); instantiationService.stub(IAgentHostFilterService, new class extends mock<IAgentHostFilterService>() {+ override readonly onDidChange = hostSelectionChanges.event;+ override get selectedHostId() { return selectedHostId; } override registerDiscoveryHandler(handler: () => Promise<void>): IDisposable { discoveryHandlers.push(handler); return toDisposable(() => { });@@ -294,9 +340,10 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: [RemoteAgentHostsEnabledSettingId]: options?.enabled ?? true, }); instantiationService.stub(IConfigurationService, configurationService);- instantiationService.stub(IAuthenticationService, new class extends mock<IAuthenticationService>() {- override readonly onDidChangeSessions = Event.None;- override readonly onDidRegisterAuthenticationProvider = Event.None;+ instantiationService.stub(IStorageService, options?.storageService ?? store.add(new InMemoryStorageService()));+ instantiationService.stub(IHostService, new class extends mock<IHostService>() {+ override readonly onDidChangeFocus = focusChanges.event;+ override get hasFocus() { return focused; } }()); instantiationService.stub(INotificationService, new class extends mock<INotificationService>() { }()); instantiationService.stub(IChatSessionsService, new class extends mock<IChatSessionsService>() {@@ -316,7 +363,9 @@ async function createContribution(store: Pick<DisposableStore, 'add'>, sessions: const contribution = store.add(instantiationService.createInstance(TestCloudSandboxContribution)); // The constructor kicks off discovery eagerly; re-running the registered handler awaits it, // because `_discoverAndSeed` serializes onto the in-flight pass.- await harness.runDiscovery();+ if (options?.waitForDiscovery !== false) {+ await harness.runDiscovery();+ } return Object.assign(harness, { contribution, configurationService }); } @@ -609,6 +658,607 @@ suite('CloudSandboxAgentHostContribution', () => { }); }); +suite('CloudSandboxAgentHostContribution startup inventory', () => {+ const store = ensureNoDisposablesAreLeakedInTestSuite();+ const account = '["github","account-1"]';+ const storageKey = `sessions.cloudSandbox.inventory.${account}`;++ function entryKey(session: ICloudSandboxDiscoveredSession, accountKey = account): string {+ return `sessions.cloudSandbox.inventory.${accountKey}.${JSON.stringify([session.environmentId, session.sessionId])}`;+ }++ function readInventory(storageService: IStorageService, accountKey = account): readonly ICloudSandboxDiscoveredSession[] {+ return storageService.keys(StorageScope.PROFILE, StorageTarget.MACHINE)+ .filter(key => key.startsWith(`sessions.cloudSandbox.inventory.${accountKey}.`))+ .flatMap(key => {+ const cached = storageService.getObject<{ sessions: ICloudSandboxDiscoveredSession[] }>(key, StorageScope.PROFILE);+ assert.ok(cached);+ return cached.sessions;+ });+ }++ class IsolatedWindowStorageService extends InMemoryStorageService {+ constructor(private readonly shared: IStorageService) {+ super();+ for (const key of shared.keys(StorageScope.PROFILE, StorageTarget.MACHINE)) {+ super.store(key, shared.get(key, StorageScope.PROFILE), StorageScope.PROFILE, StorageTarget.MACHINE, true);+ }+ }++ override store(key: string, value: StorageValue, scope: StorageScope, target: StorageTarget, external = false): void {+ super.store(key, value, scope, target, external);+ if (!external && value !== undefined && value !== null) {+ this.shared.store(key, value, scope, target);+ }+ }++ override remove(key: string, scope: StorageScope, external = false): void {+ super.remove(key, scope, external);+ if (!external) {+ this.shared.remove(key, scope);+ }+ }+ }++ test('restores session rows and repository metadata before discovery finishes without connecting', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const session = discoveredSession();+ const first = await createContribution(store, [session], { storageService });+ first.contribution.dispose();+ const pending = new DeferredPromise<ICloudSandboxDiscoveryResult>();+ const started = new DeferredPromise<void>();+ const restored = await createContribution(store, [], {+ storageService, waitForDiscovery: false,+ listSessions: async () => {+ await started.complete();+ return pending.p;+ },+ });+ await started.p;+ const provider = restored.contribution.stubProviders.get(cloudSandboxAddress(session.environmentId));++ assert.deepStrictEqual({+ cached: readInventory(storageService),+ machineKeys: storageService.keys(StorageScope.PROFILE, StorageTarget.MACHINE),+ seeded: provider?.seeded.map(meta => ({+ id: AgentSession.id(meta.session), title: meta.summary,+ modifiedTime: meta.modifiedTime, repository: meta.project?.displayName,+ })),+ connected: restored.connectedTo,+ history: restored.historyRequests,+ }, {+ cached: [session],+ machineKeys: [entryKey(session)],+ seeded: [{ id: session.sessionId, title: session.name, modifiedTime: Date.parse(session.updatedAt!), repository: session.repoName }],+ connected: [], history: [],+ });+ await pending.complete({ kind: 'complete', sessions: [session] });+ await restored.runDiscovery();+ await restored.activate(session.environmentId);+ assert.deepStrictEqual(restored.historyRequests, [session.taskId]);+ });++ test('keeps restored rows on failure and persists removals only after authoritative discovery', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const first = await createContribution(store, [discoveredSession()], { storageService });+ first.contribution.dispose();+ let result: ICloudSandboxDiscoveryResult = { kind: 'failed', reason: 'offline' };+ const restored = await createContribution(store, [], { storageService, listSessions: async () => result });+ const provider = restored.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ const retained = !provider.disposed;+ result = { kind: 'complete', sessions: [] };+ await restored.runDiscovery();+ restored.contribution.dispose();+ const next = await createContribution(store, [], {+ storageService, listSessions: async () => ({ kind: 'failed', reason: 'offline' }),+ });++ assert.deepStrictEqual({+ retained, removed: provider.disposed,+ cached: readInventory(storageService),+ reappeared: next.contribution.stubProviders.size,+ }, { retained: true, removed: true, cached: [], reappeared: 0 });+ });++ test('merges partial discoveries into the saved inventory and persists explicit removals', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const first = await createContribution(store, [discoveredSession()], { storageService });+ first.contribution.dispose();+ const other = discoveredSession({ environmentId: 'env-2', sessionId: 'sess-2', taskId: 'task-2' });+ let result: ICloudSandboxDiscoveryResult = { kind: 'partial', sessions: [other] };+ const restored = await createContribution(store, [], { storageService, listSessions: async () => result });+ const merged = readInventory(storageService);+ result = { kind: 'incremental', sessions: [], removedTaskIds: ['task-1'] };+ await restored.runDiscovery();++ assert.deepStrictEqual({+ merged, afterRemoval: readInventory(storageService),+ }, {+ merged: [discoveredSession(), other],+ afterRemoval: [other],+ });+ });++ test('refreshes existing provider metadata and persists repository replacement and removal', async () => {+ const storageService = store.add(new InMemoryStorageService());+ let result: ICloudSandboxDiscoveryResult = { kind: 'complete', sessions: [discoveredSession()] };+ const harness = await createContribution(store, [], { storageService, listSessions: async () => result });+ const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ const updated = discoveredSession({+ name: 'Renamed task', repoName: 'owner/other', updatedAt: '2026-09-22T11:00:00Z',+ });+ result = { kind: 'incremental', sessions: [updated], removedTaskIds: [] };+ await harness.runDiscovery();+ const replacedRepository = provider.seeded[0].project?.displayName;+ const withoutRepository = { ...updated, repoName: undefined };+ result = { kind: 'incremental', sessions: [withoutRepository], removedTaskIds: [] };+ await harness.runDiscovery();++ assert.deepStrictEqual({+ sameProvider: harness.contribution.stubProviders.get(cloudSandboxAddress('env-1')) === provider,+ disposed: provider.disposed,+ label: provider.label,+ title: provider.seeded[0].summary,+ modifiedTime: provider.seeded[0].modifiedTime,+ replacedRepository,+ project: provider.seeded[0].project,+ cached: readInventory(storageService).map(session => ({ name: session.name, repoName: session.repoName, updatedAt: session.updatedAt })),+ }, {+ sameProvider: true,+ disposed: false,+ label: updated.name,+ title: updated.name,+ modifiedTime: Date.parse(updated.updatedAt!),+ replacedRepository: 'owner/other',+ project: undefined,+ cached: [{ name: updated.name, repoName: undefined, updatedAt: updated.updatedAt }],+ });+ });++ test('never restores another account inventory or displays it while signed out', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const first = await createContribution(store, [discoveredSession()], { storageService });+ first.contribution.dispose();+ const other = await createContribution(store, [], {+ storageService, accountKey: '["github","account-2"]',+ listSessions: async () => ({ kind: 'failed', reason: 'offline' }),+ });+ const otherAccountRows = other.contribution.stubProviders.size;+ other.changeAccount(account);+ const ownCached = other.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ const restoredImmediately = !ownCached.disposed;+ await other.runDiscovery();+ other.changeAccount(undefined);+ await other.runDiscovery();+ const signedOut = await createContribution(store, [], { storageService, accountKey: null });++ assert.deepStrictEqual({+ otherAccountRows, restoredImmediately, hiddenOnSignOut: ownCached.disposed,+ signedOutRows: signedOut.contribution.stubProviders.size,+ signedOutRequests: signedOut.discoveryModes,+ saved: readInventory(storageService),+ }, {+ otherAccountRows: 0, restoredImmediately: true, hiddenOnSignOut: true,+ signedOutRows: 0, signedOutRequests: [],+ saved: [discoveredSession()],+ });+ });++ test('does not treat a refresh without a timestamp as new session activity', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const session = discoveredSession({ updatedAt: undefined });+ const harness = await createContribution(store, [session]);+ const provider = harness.contribution.stubProviders.get(cloudSandboxAddress(session.environmentId))!;+ const modifiedTime = provider.seeded[0].modifiedTime;+ await timeout(60_000);+ harness.discovered = [{ ...session, name: 'Renamed without a timestamp' }];+ await harness.runDiscovery();++ assert.deepStrictEqual({+ title: provider.seeded[0].summary,+ modifiedTime: provider.seeded[0].modifiedTime,+ elapsed: Date.now() - modifiedTime,+ }, { title: 'Renamed without a timestamp', modifiedTime, elapsed: 60_000 });+ }));++ test('does not dispose providers when credentials change for the same account', async () => {+ const harness = await createContribution(store, [discoveredSession()]);+ const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ harness.changeAccount(account);+ await harness.runDiscovery();++ assert.deepStrictEqual({+ disposed: provider.disposed,+ same: harness.contribution.stubProviders.get(cloudSandboxAddress('env-1')) === provider,+ }, { disposed: false, same: true });+ });++ test('ignores an old account discovery result after switching accounts', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const pending = new DeferredPromise<ICloudSandboxDiscoveryResult>();+ const started = new DeferredPromise<void>();+ let block = false;+ let oldToken: CancellationToken | undefined;+ let sessions = [discoveredSession()];+ const harness = await createContribution(store, [], {+ storageService,+ listSessions: async token => {+ if (block) {+ oldToken = token;+ await started.complete();+ return pending.p;+ }+ return { kind: 'complete', sessions };+ },+ });+ block = true;+ const previous = harness.runDiscovery();+ await started.p;+ const otherAccount = '["github","account-2"]';+ harness.changeAccount(otherAccount);+ block = false;+ sessions = [discoveredSession({ environmentId: 'env-2', sessionId: 'sess-2', taskId: 'task-2' })];+ const current = harness.runDiscovery();+ await pending.complete({ kind: 'complete', sessions: [discoveredSession({ environmentId: 'late' })] });+ await Promise.all([previous, current]);++ assert.deepStrictEqual({+ cancelled: oldToken?.isCancellationRequested,+ visible: [...harness.contribution.stubProviders].filter(([, provider]) => !provider.disposed).map(([address]) => address),+ previousAccount: readInventory(storageService),+ currentAccount: readInventory(storageService, otherAccount),+ }, {+ cancelled: true, visible: [cloudSandboxAddress('env-2')],+ previousAccount: [discoveredSession()],+ currentAccount: sessions,+ });+ });++ test('an older scan cannot discard a session provisioned in another window before storage events arrive', async () => {+ const shared = store.add(new InMemoryStorageService());+ const firstStorage = store.add(new IsolatedWindowStorageService(shared));+ const first = await createContribution(store, [discoveredSession()], { storageService: firstStorage });+ const secondStorage = store.add(new IsolatedWindowStorageService(shared));+ const pending = new DeferredPromise<ICloudSandboxDiscoveryResult>();+ const started = new DeferredPromise<void>();+ let block = false;+ const second = await createContribution(store, [], {+ storageService: secondStorage,+ listSessions: async () => {+ if (block) {+ await started.complete();+ return pending.p;+ }+ return { kind: 'complete', sessions: [discoveredSession()] };+ },+ });+ block = true;+ const olderScan = second.runDiscovery();+ await started.p;+ first.onConnect = async () => { throw new Error('offline'); };+ await assert.rejects(first.contribution.provisionSession({ prompt: 'hello' }, CancellationToken.None), /offline/);+ await pending.complete({ kind: 'complete', sessions: [discoveredSession()] });+ await olderScan;+ first.contribution.dispose();+ second.contribution.dispose();+ const restored = await createContribution(store, [], {+ storageService: store.add(new IsolatedWindowStorageService(shared)),+ listSessions: async () => ({ kind: 'failed', reason: 'offline' }),+ });++ assert.deepStrictEqual({+ staleWindowInventory: readInventory(secondStorage).map(session => session.sessionId),+ saved: readInventory(shared).map(session => session.sessionId).sort(),+ restored: [...restored.contribution.stubProviders.keys()].sort(),+ connected: restored.connectedTo,+ }, {+ staleWindowInventory: ['sess-1'],+ saved: ['sess-1', 'sess-new'],+ restored: [cloudSandboxAddress('env-1'), cloudSandboxAddress('env-new')],+ connected: [],+ });+ });++ test('does not replay unchanged inventory over another window metadata updates or removals', async () => {+ const shared = store.add(new InMemoryStorageService());+ const first = await createContribution(store, [discoveredSession()], {+ storageService: store.add(new IsolatedWindowStorageService(shared)),+ });+ const second = await createContribution(store, [discoveredSession()], {+ storageService: store.add(new IsolatedWindowStorageService(shared)),+ });+ const updated = discoveredSession({ name: 'Renamed in another window', updatedAt: '2026-09-22T11:00:00Z' });+ first.discovered = [updated];+ await first.runDiscovery();+ await second.runDiscovery();+ const afterUpdate = readInventory(shared);+ first.discovered = [];+ await first.runDiscovery();+ await second.runDiscovery();++ assert.deepStrictEqual({ afterUpdate, afterRemoval: readInventory(shared) }, {+ afterUpdate: [updated],+ afterRemoval: [],+ });+ });++ test('migrates a legacy account snapshot before awaiting network discovery', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const sessions = [discoveredSession(), discoveredSession({ environmentId: 'env-2', sessionId: 'sess-2', taskId: 'task-2' })];+ storageService.store(storageKey, { version: 1, sessions }, StorageScope.PROFILE, StorageTarget.MACHINE);+ const harness = await createContribution(store, [], {+ storageService, listSessions: async () => ({ kind: 'failed', reason: 'offline' }),+ });++ assert.deepStrictEqual({+ saved: readInventory(storageService),+ legacy: storageService.get(storageKey, StorageScope.PROFILE),+ rows: [...harness.contribution.stubProviders.keys()],+ }, {+ saved: sessions,+ legacy: undefined,+ rows: [cloudSandboxAddress('env-1'), cloudSandboxAddress('env-2')],+ });+ });++ test('restores newly provisioned sessions even when connecting failed before the next discovery', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const first = await createContribution(store, [], { storageService });+ first.onConnect = async () => { throw new Error('offline'); };+ await assert.rejects(first.contribution.provisionSession({ repoNwo: 'owner/repository', prompt: 'hello' }, CancellationToken.None), /offline/);+ first.contribution.dispose();+ const restored = await createContribution(store, [], {+ storageService, listSessions: async () => ({ kind: 'failed', reason: 'offline' }),+ });++ assert.deepStrictEqual({+ sessions: restored.contribution.stubProviders.get(cloudSandboxAddress('env-new'))?.seeded.map(meta => ({+ id: AgentSession.id(meta.session), repository: meta.project?.displayName,+ })),+ connected: restored.connectedTo,+ }, { sessions: [{ id: 'sess-new', repository: 'owner/repository' }], connected: [] });+ });++ test('does not restore inventory while disabled and restores it on enablement', async () => {+ const storageService = store.add(new InMemoryStorageService());+ const first = await createContribution(store, [discoveredSession()], { storageService });+ first.contribution.dispose();+ const restored = await createContribution(store, [], {+ storageService, enabled: false,+ listSessions: async () => ({ kind: 'failed', reason: 'offline' }),+ });+ const disabledRows = restored.contribution.stubProviders.size;+ await restored.configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true);+ await restored.setEnabled(true);+ await restored.runDiscovery();++ assert.deepStrictEqual({+ disabledRows, restoredRows: [...restored.contribution.stubProviders.keys()],+ }, { disabledRows: 0, restoredRows: [cloudSandboxAddress('env-1')] });+ });++ test('reports invalid cached inventory and continues with discovery', async () => {+ const storageService = store.add(new InMemoryStorageService());+ storageService.store(storageKey, { version: 1, sessions: [{ ...discoveredSession(), taskId: 42 }] }, StorageScope.PROFILE, StorageTarget.MACHINE);+ const logService = new TestLogService();+ const harness = await createContribution(store, [], { storageService, logService });++ assert.deepStrictEqual({+ rows: harness.contribution.stubProviders.size,+ warnings: logService.warnings,+ }, { rows: 0, warnings: ['[CloudSandboxAgentHost] Ignoring invalid cached sandbox inventory.'] });+ });++ test('continues discovery when cached inventory JSON cannot be read', async () => {+ const storageService = store.add(new InMemoryStorageService());+ storageService.store(storageKey, '{invalid', StorageScope.PROFILE, StorageTarget.MACHINE);+ const logService = new TestLogService();+ const harness = await createContribution(store, [discoveredSession()], { storageService, logService });++ assert.deepStrictEqual({+ rows: [...harness.contribution.stubProviders.keys()],+ warnings: logService.warnings,+ }, {+ rows: [cloudSandboxAddress('env-1')],+ warnings: ['[CloudSandboxAgentHost] Reading cached sandbox inventory failed.'],+ });+ });+});++suite('CloudSandboxAgentHostContribution discovery refresh', () => {+ const store = ensureNoDisposablesAreLeakedInTestSuite();++ test('shares overlapping full refreshes without an unnecessary follow-up scan', async () => {+ const pending = new DeferredPromise<ICloudSandboxDiscoveryResult>();+ let hold = false;+ const harness = await createContribution(store, [], {+ listSessions: async () => hold ? pending.p : { kind: 'complete', sessions: [] },+ });+ hold = true;+ const first = harness.runDiscovery();+ const second = harness.runDiscovery();+ await pending.complete({ kind: 'complete', sessions: [] });+ await Promise.all([first, second]);++ assert.deepStrictEqual(harness.discoveryModes, [false, false]);+ });++ test('refreshes on stale focus and host selection but not while blurred or fresh', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const harness = await createContribution(store, [], {+ listSessions: async (_token, options) => options?.incremental+ ? { kind: 'incremental', sessions: [], removedTaskIds: [] }+ : { kind: 'complete', sessions: [] },+ });+ harness.setFocused(false);+ harness.setFocused(true);+ await harness.contribution.checkForUpdates();+ const fresh = harness.discoveryModes.length;++ harness.setFocused(false);+ await timeout(60_000);+ harness.selectSandboxHost();+ await harness.contribution.checkForUpdates();+ const blurred = harness.discoveryModes.length;++ harness.setFocused(true);+ await harness.contribution.checkForUpdates();+ harness.selectSandboxHost();+ await harness.contribution.checkForUpdates();+ await timeout(60_000);+ harness.selectSandboxHost();+ await harness.contribution.checkForUpdates();++ assert.deepStrictEqual({ fresh, blurred, modes: harness.discoveryModes }, {+ fresh: 1, blurred: 1, modes: [false, true, true],+ });+ }));++ test('automatically reconciles the full inventory after the full-scan interval', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ let sessions = [discoveredSession()];+ const harness = await createContribution(store, sessions, {+ listSessions: async (_token, options) => options?.incremental+ ? { kind: 'incremental', sessions: [], removedTaskIds: [] }+ : { kind: 'complete', sessions },+ });+ const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ await timeout(60_000);+ await harness.contribution.checkForUpdates();+ const retainedAfterIncremental = !provider.disposed;+ sessions = [];+ await timeout(14 * 60_000);+ await harness.contribution.checkForUpdates();++ assert.deepStrictEqual({+ modes: harness.discoveryModes, retainedAfterIncremental, removedAfterFull: provider.disposed,+ }, { modes: [false, true, false], retainedAfterIncremental: true, removedAfterFull: true });+ }));++ test('backs off unsuccessful automatic refreshes while manual refresh bypasses staleness', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const harness = await createContribution(store, [], {+ listSessions: async (_token, options) => options?.incremental+ ? { kind: 'failed', reason: 'temporarily unavailable' }+ : { kind: 'complete', sessions: [] },+ });+ await timeout(60_000);+ await harness.contribution.checkForUpdates();+ await timeout(60_000);+ await harness.contribution.checkForUpdates();+ const duringBackoff = harness.discoveryModes.length;+ await timeout(60_000);+ await harness.contribution.checkForUpdates();+ await harness.runDiscovery();++ assert.deepStrictEqual({ duringBackoff, modes: harness.discoveryModes }, {+ duringBackoff: 2, modes: [false, true, true, false],+ });+ }));++ test('queues one full refresh when manual requests overlap an incremental scan', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const pending = new DeferredPromise<ICloudSandboxDiscoveryResult>();+ const harness = await createContribution(store, [], {+ listSessions: async (_token, options) => options?.incremental ? pending.p : { kind: 'complete', sessions: [] },+ });+ await timeout(60_000);+ const automatic = harness.contribution.checkForUpdates();+ const manual = harness.runDiscovery();+ const anotherManual = harness.runDiscovery();+ await pending.complete({ kind: 'incremental', sessions: [], removedTaskIds: [] });+ await Promise.all([automatic, manual, anotherManual]);++ assert.deepStrictEqual(harness.discoveryModes, [false, true, false]);+ }));++ test('keeps absent sessions but reconciles explicit removals from incremental and partial scans', async () => {+ let result: ICloudSandboxDiscoveryResult = {+ kind: 'complete',+ sessions: [+ discoveredSession(),+ discoveredSession({ environmentId: 'env-2', taskId: 'task-2', sessionId: 'sess-2' }),+ ],+ };+ const harness = await createContribution(store, [], { listSessions: async () => result });+ const first = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ const second = harness.contribution.stubProviders.get(cloudSandboxAddress('env-2'))!;+ result = { kind: 'incremental', sessions: [], removedTaskIds: [] };+ await harness.runDiscovery();+ const retained = [!first.disposed, !second.disposed];+ result = { kind: 'partial', sessions: [], removedTaskIds: ['task-1'] };+ await harness.runDiscovery();++ assert.deepStrictEqual({ retained, disposed: [first.disposed, second.disposed] }, {+ retained: [true, true], disposed: [true, false],+ });+ });++ test('replaces a disconnected environment when an incremental update moves its task', async () => {+ let result: ICloudSandboxDiscoveryResult = { kind: 'complete', sessions: [discoveredSession()] };+ const harness = await createContribution(store, [], { listSessions: async () => result });+ const previous = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!;+ result = {+ kind: 'incremental',+ sessions: [discoveredSession({ environmentId: 'env-replacement', sessionId: 'replacement' })],+ removedTaskIds: [],+ };+ await harness.runDiscovery();++ assert.deepStrictEqual({+ previousDisposed: previous.disposed,+ current: [...harness.contribution.stubProviders].filter(([, provider]) => !provider.disposed).map(([address]) => address),+ }, { previousDisposed: true, current: [cloudSandboxAddress('env-replacement')] });+ });++ test('re-enabling queues fresh discovery and ignores a cancelled in-flight result', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const pending = new DeferredPromise<ICloudSandboxDiscoveryResult>();+ const started = new DeferredPromise<void>();+ let incrementalToken: CancellationToken | undefined;+ const harness = await createContribution(store, [], {+ listSessions: async (token, options) => {+ if (options?.incremental) {+ incrementalToken = token;+ await started.complete();+ return pending.p;+ }+ return { kind: 'complete', sessions: [discoveredSession()] };+ },+ });+ await timeout(60_000);+ const automatic = harness.contribution.checkForUpdates();+ await started.p;+ await harness.setEnabled(false);+ await harness.setEnabled(true);+ const manual = harness.runDiscovery();+ await pending.complete({+ kind: 'incremental',+ sessions: [discoveredSession({ environmentId: 'cancelled' })],+ removedTaskIds: [],+ });+ await Promise.all([automatic, manual]);++ assert.deepStrictEqual({+ cancelled: incrementalToken?.isCancellationRequested,+ modes: harness.discoveryModes,+ current: [...harness.contribution.stubProviders].filter(([, provider]) => !provider.disposed).map(([address]) => address),+ }, { cancelled: true, modes: [false, true, false], current: [cloudSandboxAddress('env-1')] });+ }));++ test('does not automatically refresh a disabled or disposed contribution', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const harness = await createContribution(store, []);+ await harness.setEnabled(false);+ await timeout(60_000);+ harness.setFocused(true);+ harness.selectSandboxHost();+ await harness.contribution.checkForUpdates();+ await harness.setEnabled(true);+ await harness.runDiscovery();+ harness.contribution.dispose();+ await timeout(60_000);+ harness.setFocused(true);+ harness.selectSandboxHost();+ await harness.contribution.checkForUpdates();++ assert.deepStrictEqual(harness.discoveryModes, [false, false]);+ }));+});+ suite('CloudSandboxAgentHostContribution provisioning', () => { const store = ensureNoDisposablesAreLeakedInTestSuite();src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxApiService.test.ts439 + / 20 −
@@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert';-import { timeout } from '../../../../../../base/common/async.js';+import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js';-import { Event } from '../../../../../../base/common/event.js';+import { Emitter, Event } from '../../../../../../base/common/event.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js';@@ -18,13 +18,13 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IRequestService } from '../../../../../../platform/request/common/request.js';-import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js';+import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; import { CloudSandboxApiService } from '../../browser/cloudSandboxApiService.js'; import { ICloudSandboxTelemetryService } from '../../browser/cloudSandboxTelemetry.js'; function jsonResponse(body: unknown, statusCode = 200, headers: Record<string, string> = {}): IRequestContext { return {- res: { headers, statusCode },+ res: { headers: { date: new Date().toUTCString(), ...headers }, statusCode }, stream: bufferToStream(VSBuffer.fromString(JSON.stringify(body))), }; }@@ -36,16 +36,24 @@ function task(id: string, name: string, repositoryId: number | undefined, sessio name, agent_collaborators: [{ slug: CLOUD_SANDBOX_AGENT_SLUG }], compute: { provider: 'sandboxes' },+ current_environment: { id: environmentId, kind: 'managed-sandbox' }, ...(repositoryId !== undefined ? { repository: { id: repositoryId } } : {}), sessions: [{ id: sessionId, environment_id: environmentId }], }; } +type ITestTask = Omit<ReturnType<typeof task>, 'current_environment'> & {+ readonly current_environment?: { readonly id: string; readonly kind: string };+ readonly updated_at?: string;+ readonly archived_at?: string;+};+ interface ITestSetup { readonly service: CloudSandboxApiService; readonly requestedUrls: string[]; /** Peak number of task-detail fetches in flight at once during the run. */ readonly concurrency: { max: number; current: number };+ changeAuthentication(): void; } class TestLogService extends NullLogService {@@ -62,7 +70,7 @@ class TestLogService extends NullLogService { } function createService(store: Pick<{ add<T extends { dispose(): void }>(t: T): T }, 'add'>, options: {- readonly tasks: readonly unknown[];+ readonly tasks: readonly ITestTask[]; /** Repository id -> response, or 'error' to fail the lookup. */ readonly repositories: ReadonlyMap<number, { full_name?: string } | 'error'>; /** Serve page 1 with fewer rows than requested while still advertising `rel="next"`. */@@ -77,6 +85,9 @@ function createService(store: Pick<{ add<T extends { dispose(): void }>(t: T): T readonly taskFetchDelayMs?: number; readonly requestError?: Error; readonly logService?: ILogService;+ readonly onRequest?: (url: URL, token: CancellationToken) => IRequestContext | undefined | Promise<IRequestContext | undefined>;+ readonly discoveryDate?: () => string;+ readonly authenticationSessions?: (scopes?: readonly string[]) => Promise<readonly AuthenticationSession[]>; }): ITestSetup { const requestedUrls: string[] = []; const concurrency = { max: 0, current: 0 };@@ -88,14 +99,19 @@ function createService(store: Pick<{ add<T extends { dispose(): void }>(t: T): T options.retryAfterSeconds !== undefined ? { 'retry-after': String(options.retryAfterSeconds) } : {}, ); const instantiationService = store.add(new TestInstantiationService());+ const authenticationChanges = store.add(new Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>()); instantiationService.stub(IRequestService, new class extends mock<IRequestService>() {- override async request(opts: { url?: string }): Promise<IRequestContext> {+ override async request(opts: IRequestOptions, token: CancellationToken): Promise<IRequestContext> { if (options.requestError) { throw options.requestError; } const url = opts.url ?? ''; requestedUrls.push(url);+ const override = await options.onRequest?.(new URL(url), token);+ if (override) {+ return override;+ } const repoMatch = url.match(/\/repositories\/(\d+)$/); if (repoMatch) { const entry = options.repositories.get(Number(repoMatch[1]));@@ -117,7 +133,7 @@ function createService(store: Pick<{ add<T extends { dispose(): void }>(t: T): T if (options.taskFetchDelayMs !== undefined) { await timeout(options.taskFetchDelayMs); }- return jsonResponse(options.tasks.find(t => (t as { id: string }).id === id));+ return jsonResponse(options.tasks.find(t => t.id === id)); } finally { concurrency.current--; }@@ -126,31 +142,49 @@ function createService(store: Pick<{ add<T extends { dispose(): void }>(t: T): T remainingListRateLimits--; return rateLimitedResponse(); }- // Paginate like Mission Control does, advertising further pages via the `Link` header.+ const query = new URL(url).searchParams;+ const tasks = options.tasks.filter(task => {+ if (query.has('with_repo') && (task.repository?.id !== undefined) !== (query.get('with_repo') === 'true')) {+ return false;+ }+ if (query.has('include_environment_kinds') && task.current_environment?.kind !== query.get('include_environment_kinds')) {+ return false;+ }+ return !query.has('since') || !task.updated_at || Date.parse(task.updated_at) >= Date.parse(query.get('since')!);+ }); const perPage = Number(url.match(/[?&]per_page=(\d+)/)?.[1] ?? options.tasks.length); const page = Number(url.match(/[?&]page=(\d+)/)?.[1] ?? 1);- if (options.shortFirstPage && page === 1) {+ if (options.shortFirstPage && tasks.length > 0 && page === 1) { return jsonResponse({ tasks: [] }, 200, { link: `<https://api.github.com/agents/tasks?page=2&per_page=${perPage}>; rel="next"` }); }- const slice = options.shortFirstPage ? options.tasks : options.tasks.slice((page - 1) * perPage, page * perPage);- const hasNext = !options.shortFirstPage && page * perPage < options.tasks.length;+ const slice = options.shortFirstPage ? tasks : tasks.slice((page - 1) * perPage, page * perPage);+ const hasNext = !options.shortFirstPage && page * perPage < tasks.length; const link = hasNext ? `<https://api.github.com/agents/tasks?page=${page + 1}&per_page=${perPage}>; rel="next"` : `<https://api.github.com/agents/tasks?page=${page}&per_page=${perPage}>; rel="last"`;- return jsonResponse({ tasks: slice }, 200, { link });+ return jsonResponse({ tasks: slice }, 200, { link, date: options.discoveryDate?.() ?? new Date().toUTCString() }); } }()); instantiationService.stub(IAuthenticationService, new class extends mock<IAuthenticationService>() {- override async getSessions() { return [{ accessToken: 'tok', id: 's', account: { id: 'a', label: 'a' }, scopes: [] }]; }- override readonly onDidChangeSessions = Event.None;+ override async getSessions(_providerId: string, scopes?: readonly string[]) {+ return options.authenticationSessions ? options.authenticationSessions(scopes) : [{ accessToken: 'tok', id: 's', account: { id: 'a', label: 'a' }, scopes: [] }];+ }+ override readonly onDidChangeSessions = authenticationChanges.event;+ override readonly onDidRegisterAuthenticationProvider = Event.None;+ override readonly onDidUnregisterAuthenticationProvider = Event.None; }()); instantiationService.stub(IProductService, { defaultChatAgent: undefined } as unknown as IProductService); instantiationService.stub(ILogService, options.logService ?? new NullLogService()); instantiationService.stub(ICloudSandboxTelemetryService, new class extends mock<ICloudSandboxTelemetryService>() { override reportRequest(): void { } }()); - return { service: store.add(instantiationService.createInstance(CloudSandboxApiService)), requestedUrls, concurrency };+ return {+ service: store.add(instantiationService.createInstance(CloudSandboxApiService)),+ requestedUrls,+ concurrency,+ changeAuthentication: () => authenticationChanges.fire({ providerId: 'github', label: 'GitHub', event: { added: [], removed: [], changed: [] } }),+ }; } suite('CloudSandboxApiService repository resolution', () => {@@ -197,7 +231,7 @@ suite('CloudSandboxApiService repository resolution', () => { }); }); - test('normalizes a transport failure after cancellation without error logging', async () => {+ test('does not start discovery reads when already cancelled', async () => { const logService = new TestLogService(); const cancellation = store.add(new CancellationTokenSource()); cancellation.cancel();@@ -213,7 +247,7 @@ suite('CloudSandboxApiService repository resolution', () => { cancelledTraces: logService.traces.filter(message => message.includes(' -> cancelled')).length, errors: logService.errors, }, {- cancelledTraces: 1,+ cancelledTraces: 0, errors: [], }); });@@ -294,7 +328,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'complete', found: ['sess-old'],- listPages: 2,+ listPages: 3, }); }); @@ -316,7 +350,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'complete', found: ['sess-old'],- listPages: 2,+ listPages: 3, }); }); @@ -335,8 +369,391 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'partial', sessions: 1000,- listPages: 10,+ listPages: 11,+ });+ });+});++suite('CloudSandboxApiService discovery account', () => {+ const store = ensureNoDisposablesAreLeakedInTestSuite();++ test('resolves a credential-free account key without issuing HTTP requests', async () => {+ const { service, requestedUrls } = createService(store, { tasks: [], repositories: new Map() });++ assert.deepStrictEqual({ accountKey: await service.getAccountKey(), requestedUrls }, {+ accountKey: '["github","a"]', requestedUrls: [],+ });+ });++ test('uses the same narrowest eligible authentication session as task requests', async () => {+ const scopes = ['read:user', 'user:email', 'repo', 'workflow'];+ const { service } = createService(store, {+ tasks: [], repositories: new Map(),+ authenticationSessions: async requestedScopes => requestedScopes ? [] : [+ { id: 'wide-session', accessToken: 'wide-token', account: { id: 'wide', label: 'Wide' }, scopes: [...scopes, 'gist'] },+ { id: 'narrow-session', accessToken: 'narrow-token', account: { id: 'narrow', label: 'Narrow' }, scopes },+ ],+ });++ assert.strictEqual(await service.getAccountKey(), '["github","narrow"]');+ });++ test('announces sign-out after authentication changes', async () => {+ const { service, changeAuthentication } = createService(store, {+ tasks: [], repositories: new Map(), authenticationSessions: async () => [],+ });+ const changed = Event.toPromise(service.onDidChangeAccount);+ changeAuthentication();++ assert.deepStrictEqual([await service.getAccountKey(), await changed], [undefined, undefined]);+ });++ test('does not mistake a failed authentication lookup for signing out', async () => {+ const { service, changeAuthentication } = createService(store, {+ tasks: [], repositories: new Map(),+ authenticationSessions: async () => { throw new Error('provider temporarily unavailable'); },+ });+ const changes: (string | undefined)[] = [];+ store.add(service.onDidChangeAccount(account => changes.push(account)));+ changeAuthentication();+ await assert.rejects(service.getAccountKey(), /requires a signed-in GitHub account/);++ assert.deepStrictEqual(changes, []);+ });++ test('rejects an account lookup overtaken by an authentication change', async () => {+ const pending = new DeferredPromise<readonly AuthenticationSession[]>();+ let hold = true;+ const { service, changeAuthentication } = createService(store, {+ tasks: [], repositories: new Map(),+ authenticationSessions: async () => hold ? pending.p : [],+ });+ const oldAccount = service.getAccountKey();+ const rejected = assert.rejects(oldAccount, isCancellationError);+ hold = false;+ changeAuthentication();+ await pending.complete([{ id: 'old-session', accessToken: 'old-token', account: { id: 'old', label: 'Old' }, scopes: [] }]);+ await rejected;+ });+});++suite('CloudSandboxApiService incremental discovery', () => {+ const store = ensureNoDisposablesAreLeakedInTestSuite();+ const firstScanDate = 'Tue, 22 Sep 2026 10:00:00 GMT';+ const checkpoint = '2026-09-22T09:59:00.000Z';++ function updatedTask(id: string, updatedAt = '2026-09-22T09:58:00Z', repositoryId?: number): ITestTask {+ return { ...task(id, id, repositoryId, `session-${id}`, `env-${id}`), updated_at: updatedAt };+ }++ test('queries both repository scopes and resolves no task details when nothing changed', async () => {+ const { service, requestedUrls } = createService(store, {+ tasks: [updatedTask('old')],+ repositories: new Map(),+ discoveryDate: () => firstScanDate,+ });+ await service.listSessions(CancellationToken.None);+ requestedUrls.length = 0;++ const result = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ result,+ queries: requestedUrls.map(url => Object.fromEntries(new URL(url).searchParams)),+ }, {+ result: { kind: 'incremental', sessions: [], removedTaskIds: [] },+ queries: [true, false].map(withRepository => ({+ per_page: '100', page: '1', sort: 'updated_at', direction: 'desc',+ with_repo: String(withRepository), since: checkpoint, include_environment_kinds: 'managed-sandbox',+ })),+ });+ });++ test('reuses unchanged details in the overlap and fetches new and changed tasks', async () => {+ const tasks = [updatedTask('unchanged', checkpoint), updatedTask('changed', checkpoint)];+ const { service, requestedUrls } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => firstScanDate,+ });+ await service.listSessions(CancellationToken.None);+ tasks[1] = { ...tasks[1], name: 'Changed elsewhere', updated_at: '2026-09-22T10:00:00Z' };+ tasks.push(updatedTask('new', '2026-09-22T10:00:00Z'));+ requestedUrls.length = 0;++ const result = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ kind: result.kind,+ names: result.kind === 'failed' ? [] : result.sessions.map(session => session.name),+ details: requestedUrls.filter(url => new URL(url).pathname.startsWith('/agents/tasks/')).map(url => new URL(url).pathname),+ }, {+ kind: 'incremental',+ names: ['unchanged', 'Changed elsewhere', 'new'],+ details: ['/agents/tasks/changed', '/agents/tasks/new'],+ });+ });++ test('uses task timestamps when the browser cannot read the response Date header', async () => {+ const { service, requestedUrls } = createService(store, {+ tasks: [updatedTask('old')], repositories: new Map(), discoveryDate: () => '',+ });+ await service.listSessions(CancellationToken.None);+ requestedUrls.length = 0;+ const result = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ kind: result.kind,+ since: new URL(requestedUrls[0]).searchParams.get('since'),+ requests: requestedUrls.length,+ }, { kind: 'incremental', since: '2026-09-22T09:57:00.000Z', requests: 2 });+ });++ test('preserves incremental filters on every page even when pagination links omit them', async () => {+ const tasks: ITestTask[] = [];+ const { service, requestedUrls } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => firstScanDate,+ });+ await service.listSessions(CancellationToken.None);+ tasks.push(...Array.from({ length: 101 }, (_, i) => updatedTask(`new-${i}`, checkpoint)));+ requestedUrls.length = 0;++ const result = await service.listSessions(CancellationToken.None, { incremental: true });+ const queries = requestedUrls.filter(url => new URL(url).pathname === '/agents/tasks').map(url => new URL(url).searchParams);++ assert.deepStrictEqual({+ kind: result.kind,+ count: result.kind === 'failed' ? 0 : result.sessions.length,+ pages: queries.map(query => [query.get('with_repo'), query.get('page')]),+ retainedFilters: queries.every(query => query.get('since') === checkpoint && query.get('include_environment_kinds') === 'managed-sandbox'),+ }, {+ kind: 'incremental', count: 101,+ pages: [['true', '1'], ['false', '1'], ['false', '2']],+ retainedFilters: true,+ });+ });++ test('reports explicit task archives and discovers unarchives without retaining stale details', async () => {+ const tasks = [updatedTask('first')];+ const { service, requestedUrls } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => firstScanDate, });+ await service.listSessions(CancellationToken.None);+ tasks[0] = { ...tasks[0], archived_at: checkpoint, updated_at: checkpoint };+ requestedUrls.length = 0;+ const archived = await service.listSessions(CancellationToken.None, { incremental: true });+ const archivedRequests = requestedUrls.length;+ tasks[0] = { ...tasks[0], archived_at: undefined };+ const unarchived = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ archived, archivedRequests,+ unarchived: unarchived.kind === 'failed' ? [] : unarchived.sessions.map(session => session.taskId),+ detailFetches: requestedUrls.filter(url => url.endsWith('/tasks/first')).length,+ }, {+ archived: { kind: 'incremental', sessions: [], removedTaskIds: ['first'] },+ archivedRequests: 2,+ unarchived: ['first'],+ detailFetches: 1,+ });+ });++ test('removes a previously discovered task when its environment binding disappears', async () => {+ const tasks = [updatedTask('first')];+ const { service } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => firstScanDate,+ });+ await service.listSessions(CancellationToken.None);+ tasks[0] = { ...updatedTask('first', checkpoint), sessions: [] };++ const removed = await service.listSessions(CancellationToken.None, { incremental: true });+ const stillUnbound = await service.listSessions(CancellationToken.None, { incremental: true });+ tasks[0] = updatedTask('first', checkpoint);+ const rebound = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ removed,+ stillUnbound,+ rebound: rebound.kind === 'failed' ? [] : rebound.sessions.map(session => session.taskId),+ }, {+ removed: { kind: 'incremental', sessions: [], removedTaskIds: ['first'] },+ stillUnbound: { kind: 'incremental', sessions: [], removedTaskIds: [] },+ rebound: ['first'],+ });+ });++ test('retains a failed task until retry confirms its binding disappeared, even outside the discovery window', async () => {+ const tasks = [updatedTask('first')];+ let failDetail = false;+ let omitFromList = false;+ const { service } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => firstScanDate,+ onRequest: url => {+ if (failDetail && url.pathname.endsWith('/tasks/first')) {+ return jsonResponse({}, 500);+ }+ if (omitFromList && url.pathname.endsWith('/tasks')) {+ return jsonResponse({ tasks: [] }, 200, { date: firstScanDate });+ }+ return undefined;+ },+ });+ await service.listSessions(CancellationToken.None);+ tasks[0] = { ...updatedTask('first', checkpoint), sessions: [] };+ failDetail = true;+ const failed = await service.listSessions(CancellationToken.None, { incremental: true });+ failDetail = false;+ omitFromList = true;+ const retried = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({ failed, retried }, {+ failed: { kind: 'partial', sessions: [], removedTaskIds: [] },+ retried: { kind: 'incremental', sessions: [], removedTaskIds: ['first'] },+ });+ });++ test('does not advance the checkpoint past an unresolved task', async () => {+ const tasks: ITestTask[] = [];+ let failing = false;+ let date = firstScanDate;+ const { service, requestedUrls } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => date,+ onRequest: url => failing && url.pathname.endsWith('/tasks/new') ? jsonResponse({}, 500) : undefined,+ });+ await service.listSessions(CancellationToken.None);+ tasks.push(updatedTask('new', checkpoint));+ date = 'Tue, 22 Sep 2026 10:05:00 GMT';+ failing = true;+ const partial = await service.listSessions(CancellationToken.None, { incremental: true });+ failing = false;+ requestedUrls.length = 0;+ const recovered = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ firstKind: partial.kind,+ recovered: recovered.kind === 'failed' ? [] : recovered.sessions.map(session => session.taskId),+ since: new URL(requestedUrls[0]).searchParams.get('since'),+ }, { firstKind: 'partial', recovered: ['new'], since: checkpoint });+ });++ test('does not advance the checkpoint after a later list page fails', async () => {+ const tasks: ITestTask[] = [];+ let failSecondPage = false;+ let date = firstScanDate;+ const { service, requestedUrls } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => date,+ onRequest: url => failSecondPage && url.searchParams.get('page') === '2' ? jsonResponse({}, 500) : undefined,+ });+ await service.listSessions(CancellationToken.None);+ tasks.push(...Array.from({ length: 101 }, (_, i) => updatedTask(`new-${i}`, checkpoint)));+ date = 'Tue, 22 Sep 2026 10:05:00 GMT';+ failSecondPage = true;+ const partial = await service.listSessions(CancellationToken.None, { incremental: true });+ failSecondPage = false;+ requestedUrls.length = 0;+ const recovered = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ firstKind: partial.kind,+ recovered: recovered.kind === 'failed' ? 0 : recovered.sessions.length,+ since: new URL(requestedUrls[0]).searchParams.get('since'),+ details: requestedUrls.filter(url => new URL(url).pathname.startsWith('/agents/tasks/')).length,+ }, { firstKind: 'partial', recovered: 101, since: checkpoint, details: 1 });+ });++ test('retries missing bindings and repository names even if the task falls outside the incremental window', async () => {+ const tasks = [updatedTask('binding'), updatedTask('repository', undefined, 42)];+ tasks[0] = { ...tasks[0], sessions: [] };+ const repositories = new Map<number, { full_name?: string } | 'error'>([[42, 'error']]);+ const { service, requestedUrls } = createService(store, {+ tasks, repositories, discoveryDate: () => firstScanDate,+ });+ await service.listSessions(CancellationToken.None);+ tasks[0] = updatedTask('binding');+ repositories.set(42, { full_name: 'owner/repository' });+ requestedUrls.length = 0;++ const result = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ sessions: result.kind === 'failed' ? [] : result.sessions.map(session => [session.taskId, session.repoName]),+ details: requestedUrls.filter(url => new URL(url).pathname.startsWith('/agents/tasks/')).map(url => new URL(url).pathname),+ }, {+ sessions: [['binding', undefined], ['repository', 'owner/repository']],+ details: ['/agents/tasks/binding'],+ });+ });++ test('full reconciliation includes repository-less tasks and tasks missing environment metadata', async () => {+ const tasks = [updatedTask('repository', undefined, 42), { ...updatedTask('without-metadata'), current_environment: undefined }];+ const { service, requestedUrls } = createService(store, {+ tasks, repositories: new Map([[42, { full_name: 'owner/repository' }]]), discoveryDate: () => firstScanDate,+ });+ const full = await service.listSessions(CancellationToken.None);+ tasks.pop();+ await service.listSessions(CancellationToken.None);+ tasks.push({ ...updatedTask('without-metadata'), current_environment: undefined });+ requestedUrls.length = 0;+ const restored = await service.listSessions(CancellationToken.None);++ assert.deepStrictEqual({+ initial: full.kind === 'failed' ? [] : full.sessions.map(session => session.taskId),+ restored: restored.kind === 'failed' ? [] : restored.sessions.map(session => session.taskId),+ details: requestedUrls.filter(url => new URL(url).pathname.startsWith('/agents/tasks/')).map(url => new URL(url).pathname),+ broad: requestedUrls.every(url => !new URL(url).searchParams.has('include_environment_kinds')),+ }, {+ initial: ['repository', 'without-metadata'], restored: ['repository', 'without-metadata'],+ details: ['/agents/tasks/without-metadata'], broad: true,+ });+ });++ test('cancellation does not publish a checkpoint or cache partly resolved tasks', async () => {+ const cancellation = store.add(new CancellationTokenSource());+ let cancel = true;+ const { service, requestedUrls } = createService(store, {+ tasks: [updatedTask('old')], repositories: new Map(), discoveryDate: () => firstScanDate,+ onRequest: url => {+ if (cancel && url.pathname.endsWith('/tasks/old')) {+ cancellation.cancel();+ }+ return undefined;+ },+ });+ await assert.rejects(service.listSessions(cancellation.token), isCancellationError);+ cancel = false;+ requestedUrls.length = 0;+ const result = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ kind: result.kind,+ since: new URL(requestedUrls[0]).searchParams.get('since'),+ detailFetches: requestedUrls.filter(url => url.endsWith('/tasks/old')).length,+ }, { kind: 'complete', since: null, detailFetches: 1 });+ });++ test('authentication changes invalidate cached tasks and the incremental checkpoint', async () => {+ const tasks = [updatedTask('old')];+ const response = new DeferredPromise<IRequestContext>();+ let paused = false;+ const { service, requestedUrls, changeAuthentication } = createService(store, {+ tasks, repositories: new Map(), discoveryDate: () => firstScanDate,+ onRequest: url => paused && url.pathname.endsWith('/tasks/new') ? response.p : undefined,+ });+ await service.listSessions(CancellationToken.None);+ tasks.push(updatedTask('new', checkpoint));+ paused = true;+ const inFlight = service.listSessions(CancellationToken.None, { incremental: true });+ changeAuthentication();+ await response.complete(jsonResponse(tasks[1]));+ const stale = await inFlight;+ paused = false;+ requestedUrls.length = 0;+ const refreshed = await service.listSessions(CancellationToken.None, { incremental: true });++ assert.deepStrictEqual({+ staleKind: stale.kind,+ refreshedKind: refreshed.kind,+ since: new URL(requestedUrls[0]).searchParams.get('since'),+ details: requestedUrls.filter(url => new URL(url).pathname.startsWith('/agents/tasks/')).length,+ }, { staleKind: 'failed', refreshedKind: 'complete', since: null, details: 2 }); }); }); @@ -500,6 +917,8 @@ function createServiceForCreate(store: Pick<{ add<T extends { dispose(): void }> instantiationService.stub(IAuthenticationService, new class extends mock<IAuthenticationService>() { override async getSessions() { return [{ accessToken: 'tok', id: 's', account: { id: 'a', label: 'a' }, scopes: [] }]; } override readonly onDidChangeSessions = Event.None;+ override readonly onDidRegisterAuthenticationProvider = Event.None;+ override readonly onDidUnregisterAuthenticationProvider = Event.None; }()); instantiationService.stub(IProductService, { defaultChatAgent: undefined } as unknown as IProductService); instantiationService.stub(ILogService, new class extends NullLogService {src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts313 + / 0 −
@@ -413,6 +413,41 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(provider.sessionTypes[0].label, 'Copilot'); }); + test('setLabel refreshes provider and workspace labels and notifies picker consumers', () => {+ const provider = createProvider(disposables, connection, { noConnection: true, isWebPlatform: false });+ provider.seedSessions([createSession('renamed-host', {+ project: { uri: URI.parse('https://github.com/owner/repo'), displayName: 'owner/repo' },+ })]);+ const session = provider.getSessions()[0];+ const labels: string[] = [];+ disposables.add(provider.onDidChangeSessionTypes(() => labels.push(provider.label)));++ provider.setLabel('Renamed Host');+ provider.setLabel('Renamed Host');+ const renamed = {+ label: provider.label,+ description: provider.browseActions[0].description,+ workspace: session.workspace.get()?.label,+ };+ provider.setLabel('');++ assert.deepStrictEqual({+ renamed,+ fallback: provider.label,+ fallbackDescription: provider.browseActions[0].description,+ fallbackWorkspace: session.workspace.get()?.label,+ labels,+ sameSession: provider.getSessions()[0] === session,+ }, {+ renamed: { label: 'Renamed Host', description: 'Renamed Host', workspace: 'owner/repo [Renamed Host]' },+ fallback: 'localhost:4321',+ fallbackDescription: 'localhost:4321',+ fallbackWorkspace: 'owner/repo [localhost:4321]',+ labels: ['Renamed Host', 'localhost:4321'],+ sameSession: true,+ });+ });+ test('creates workspace-less quick chats on the remote provider', () => { const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' }); const session = provider.createQuickChat(provider.sessionTypes[0].id);@@ -2185,6 +2220,27 @@ suite('RemoteAgentHostSessionsProvider', () => { }); })); + test('seedSessions keeps existing remote metadata unless discovery refresh is requested', () => {+ const provider = createProvider(disposables, connection, { noConnection: true, omitHostFromWorkspaceLabel: true });+ provider.seedSessions([createSession('seeded-1', {+ summary: 'Original',+ project: { uri: URI.parse('https://github.com/owner/original'), displayName: 'owner/original' },+ })]);+ const session = provider.getSessions()[0];+ provider.seedSessions([createSession('seeded-1', {+ summary: 'Changed',+ modifiedTime: 4000,+ project: { uri: URI.parse('https://github.com/owner/changed'), displayName: 'owner/changed' },+ })]);++ assert.deepStrictEqual({+ title: session.title.get(),+ modifiedTime: session.updatedAt.get().getTime(),+ project: session.workspace.get()?.label,+ sameSession: provider.getSessions()[0] === session,+ }, { title: 'Original', modifiedTime: 2000, project: 'owner/original', sameSession: true });+ });+ test('non-web: omitHostFromWorkspaceLabel drops the [host] suffix so sessions group by repository', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const projectUri = URI.parse('vscode-agent-host://localhost__4321/home/user/vscode?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0'); connection.addSession(createSession('sandbox-1', {@@ -2228,6 +2284,263 @@ suite('RemoteAgentHostSessionsProvider', () => { }); +suite('CloudSandboxSessionsProvider discovery metadata', () => {+ const disposables = new DisposableStore();+ const originalProject = { uri: URI.parse('https://github.com/owner/original'), displayName: 'owner/original' };+ const metadata = createSession('discovered-session', {+ provider: 'copilot',+ summary: 'Original task',+ project: originalProject,+ });+ const replacementProject = { uri: URI.parse('https://github.com/owner/replacement'), displayName: 'owner/replacement' };+ const backendResource = AgentSession.uri('ahp-session', 'discovered-session');+ let connection: MockAgentConnection;++ setup(() => {+ connection = disposables.add(new MockAgentConnection());+ });++ teardown(() => {+ disposables.clear();+ });++ ensureNoDisposablesAreLeakedInTestSuite();++ function createSandboxProvider(storageService?: IStorageService): CloudSandboxSessionsProvider {+ return createProvider(disposables, connection, {+ address: 'cloudsandbox:discovery-test',+ ctor: CloudSandboxSessionsProvider,+ sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' },+ omitHostFromWorkspaceLabel: true,+ noConnection: true,+ storageService,+ }) as CloudSandboxSessionsProvider;+ }++ function seed(provider: RemoteAgentHostSessionsProvider, changes?: Partial<IAgentSessionMetadata>): void {+ provider.seedSessions([{ ...metadata, ...changes }], { updateExisting: true });+ }++ function snapshot(session: ISession) {+ return {+ title: session.title.get(),+ modifiedTime: session.updatedAt.get().getTime(),+ project: session.workspace.get()?.label,+ };+ }++ test('discovery refreshes a provisional session without publishing or replacing it', () => {+ const provider = createSandboxProvider();+ provider.seedProvisionalSession(metadata);+ const seeded = provider.getCachedSession('discovered-session');+ seed(provider, { summary: 'Discovered task', modifiedTime: 4000, project: replacementProject });+ const listedBeforePublish = provider.getSessions().length;+ provider.publishWithheldSession('discovered-session');+ const session = provider.getSessions()[0];++ assert.deepStrictEqual({+ ...snapshot(session),+ listedBeforePublish,+ sameSession: session === seeded,+ }, {+ title: 'Discovered task',+ modifiedTime: 4000,+ project: 'owner/replacement',+ listedBeforePublish: 0,+ sameSession: true,+ });+ });++ test('repeated discovery refreshes titles and modified times without replacing the session', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const provider = createSandboxProvider();+ seed(provider);+ const session = provider.getSessions()[0];+ await timeout(0);+ const events: { added: number; changed: boolean; removed: number }[] = [];+ disposables.add(provider.onDidChangeSessions(event => events.push({+ added: event.added.length,+ changed: event.changed.length === 1 && event.changed[0] === session,+ removed: event.removed.length,+ })));++ seed(provider, { summary: 'Renamed task', modifiedTime: 4000 });+ await timeout(0);+ seed(provider, { summary: 'Latest task', modifiedTime: 5000 });+ await timeout(0);+ seed(provider, { summary: 'Latest task', modifiedTime: 5000, project: { ...originalProject, uri: URI.parse(originalProject.uri.toString()) } });+ await timeout(0);++ assert.deepStrictEqual({+ ...snapshot(session),+ sameSession: provider.getSessions()[0] === session,+ createdAt: session.createdAt.getTime(),+ status: session.status.get(),+ events,+ }, {+ title: 'Latest task',+ modifiedTime: 5000,+ project: 'owner/original',+ sameSession: true,+ createdAt: 1000,+ status: SessionStatus.Completed,+ events: [+ { added: 0, changed: true, removed: 0 },+ { added: 0, changed: true, removed: 0 },+ ],+ });+ }));++ test('repository replacement and removal survive reload and can be discovered again', async () => {+ const storageService = disposables.add(new InMemoryStorageService());+ let provider = createSandboxProvider(storageService);+ seed(provider);+ const projects: (string | undefined)[] = [];+ const identities: boolean[] = [];+ for (const project of [replacementProject, undefined, originalProject]) {+ const session = provider.getSessions()[0];+ seed(provider, { project });+ projects.push(session.workspace.get()?.label);+ identities.push(provider.getSessions()[0] === session);+ await storageService.flush();+ provider.dispose();+ provider = createSandboxProvider(storageService);+ projects.push(provider.getSessions()[0].workspace.get()?.label);+ }++ assert.deepStrictEqual({ projects, identities }, {+ projects: [+ 'owner/replacement', 'owner/replacement',+ undefined, undefined,+ 'owner/original', 'owner/original',+ ],+ identities: [true, true, true],+ });+ });++ test('restored provider summaries pick up later discovery changes and persist their baseline', async () => {+ const storageService = disposables.add(new InMemoryStorageService());+ const first = createSandboxProvider(storageService);+ first.seedSessions([metadata]);+ await storageService.flush();+ first.dispose();++ const restored = createSandboxProvider(storageService);+ const session = restored.getSessions()[0];+ seed(restored);+ seed(restored, { summary: 'Renamed after restoration', modifiedTime: 4000, project: replacementProject });+ const afterDiscovery = snapshot(session);+ const sameSession = restored.getSessions()[0] === session;+ await storageService.flush();+ restored.dispose();++ const restoredAgain = createSandboxProvider(storageService);+ const beforeNextDiscovery = snapshot(restoredAgain.getSessions()[0]);+ seed(restoredAgain, { summary: 'Latest discovery', modifiedTime: 5000, project: undefined });++ assert.deepStrictEqual({+ afterDiscovery,+ beforeNextDiscovery,+ afterNextDiscovery: snapshot(restoredAgain.getSessions()[0]),+ sameSession,+ }, {+ afterDiscovery: { title: 'Renamed after restoration', modifiedTime: 4000, project: 'owner/replacement' },+ beforeNextDiscovery: { title: 'Renamed after restoration', modifiedTime: 4000, project: 'owner/replacement' },+ afterNextDiscovery: { title: 'Latest discovery', modifiedTime: 5000, project: undefined },+ sameSession: true,+ });+ });++ for (const discoveredFirst of [false, true]) {+ test(`discovery preserves host metadata ${discoveredFirst ? 'after a seeded session connects' : 'when the host was discovered first'}`, () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const storageService = disposables.add(new InMemoryStorageService());+ const provider = createSandboxProvider(storageService);+ if (discoveredFirst) {+ seed(provider);+ }+ const hostMetadata = {+ ...metadata,+ session: backendResource,+ summary: 'Host title',+ modifiedTime: 9000,+ project: { uri: URI.parse('file:///workspaces/host-repo'), displayName: 'Host repository' },+ workingDirectories: [URI.parse('file:///workspaces/host-repo')],+ status: ProtocolSessionStatus.InProgress,+ };+ connection.addSession(hostMetadata);+ provider.setConnection(connection);+ await timeout(0);+ const session = provider.getSessions()[0];+ seed(provider, { summary: 'Stale discovery', modifiedTime: 3000, project: replacementProject });+ const connected = { ...snapshot(session), status: session.status.get() };+ provider.clearConnection();+ seed(provider, hostMetadata);+ seed(provider, { summary: 'Another stale discovery', modifiedTime: 4000, project: undefined });+ const disconnected = snapshot(session);+ const sameSession = provider.getSessions()[0] === session;+ await storageService.flush();+ provider.dispose();++ const restored = createSandboxProvider(storageService);+ seed(restored, { summary: 'Discovery after reload', modifiedTime: 5000, project: replacementProject });++ assert.deepStrictEqual({+ connected,+ disconnected,+ restored: snapshot(restored.getSessions()[0]),+ sameSession,+ }, {+ connected: { title: 'Host title', modifiedTime: 9000, project: 'Host repository', status: SessionStatus.InProgress },+ disconnected: { title: 'Host title', modifiedTime: 9000, project: 'Host repository' },+ restored: { title: 'Host title', modifiedTime: 9000, project: 'Host repository' },+ sameSession: true,+ });+ }));+ }++ test('updates untouched discovery fields while preserving a host title and live status', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const provider = createSandboxProvider();+ seed(provider);+ const session = provider.getSessions()[0];+ connection.addSession({ ...metadata, session: backendResource, summary: 'Host title', status: ProtocolSessionStatus.InProgress });+ provider.setConnection(connection);+ await timeout(0);+ seed(provider, { summary: 'Discovery title', modifiedTime: 4000, project: replacementProject });++ assert.deepStrictEqual({+ ...snapshot(session),+ status: session.status.get(),+ sameSession: provider.getSessions()[0] === session,+ }, {+ title: 'Host title',+ modifiedTime: 4000,+ project: 'owner/replacement',+ status: SessionStatus.InProgress,+ sameSession: true,+ });+ }));++ test('does not replace a project cleared by the host or its real working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const provider = createSandboxProvider();+ seed(provider);+ const session = provider.getSessions()[0];+ connection.addSession({+ ...metadata,+ session: backendResource,+ project: undefined,+ workingDirectories: [URI.parse('file:///workspaces/host-directory')],+ });+ provider.setConnection(connection);+ await timeout(0);+ seed(provider, { summary: 'Discovery title', modifiedTime: 4000, project: replacementProject });++ assert.deepStrictEqual(snapshot(session), {+ title: 'Discovery title',+ modifiedTime: 4000,+ project: 'host-directory',+ });+ }));+});+ suite('CloudSandboxSessionsProvider archiving', () => { const disposables = new DisposableStore(); const metadata = createSession('sandbox-session', { provider: 'copilot', summary: 'Sandbox Session' });src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts2 + / 3 −
@@ -45,16 +45,15 @@ class StubProvider extends mock<RemoteAgentHostSessionsProvider>() { override readonly id: string; override readonly remoteAddress: string;- override readonly label: string;+ override get label(): string { return this.name; } private readonly _status = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.connecting); override readonly connectionStatus = this._status; - constructor(address: string, name: string) {+ constructor(address: string, private readonly name: string) { super(); this.id = `agenthost-${address}`; this.remoteAddress = address;- this.label = name; } override setConnectionStatus(status: RemoteAgentHostConnectionStatus): void {src/vs/sessions/services/sessions/browser/sessionsManagementService.ts14 + / 6 −
@@ -113,7 +113,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa ) { super(); - // Subscribe to provider changes for session type updates+ // Subscribe to provider changes for session and session type updates this._register(this.sessionsProvidersService.onDidChangeProviders(e => { this._onProvidersChanged(e); this._updateSessionTypes();@@ -149,23 +149,31 @@ export class SessionsManagementService extends Disposable implements ISessionsMa for (const provider of e.removed) { this._providerListeners.deleteAndDispose(provider.id); }- if (e.added.length) {- this._subscribeToProviders(e.added);- }+ const added = this._subscribeToProviders(e.added);+ // Provider disappearance hides sessions; reporting removals would trigger deletion cleanup.+ this._onDidChangeSessions.fire({ added, removed: [], changed: [] }); } - private _subscribeToProviders(providers: readonly ISessionsProvider[]): void {+ private _subscribeToProviders(providers: readonly ISessionsProvider[]): ISession[] {+ const added: ISession[] = []; for (const provider of providers) {+ // Reading can synchronously announce cache population, so seed before subscribing.+ const sessions = provider.getSessions();+ if (this.sessionsProvidersService.getProvider(provider.id) !== provider) {+ continue;+ } const disposables = new DisposableStore();+ this._providerListeners.set(provider.id, disposables);+ added.push(...sessions); disposables.add(provider.onDidChangeSessions(e => this.onDidChangeSessionsFromSessionsProviders(e))); if (provider.onDidReplaceSession) { disposables.add(provider.onDidReplaceSession(e => this._handleDidReplaceSession(e.from, e.to))); } if (provider.onDidChangeSessionTypes) { disposables.add(provider.onDidChangeSessionTypes(() => this._updateSessionTypes())); }- this._providerListeners.set(provider.id, disposables); }+ return added; } private _handleDidReplaceSession(from: ISession, to: ISession): void {src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts314 + / 7 −
@@ -8,13 +8,13 @@ import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js';-import { toDisposable } from '../../../../../base/common/lifecycle.js';+import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, constObservable, observableValue } from '../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Codicon } from '../../../../../base/common/codicons.js';-import { mock } from '../../../../../base/test/common/mock.js';+import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';@@ -47,7 +47,7 @@ import { ISessionOpenTelemetryService, SessionOpenTelemetryService } from '../.. import { ISessionsPartService } from '../../browser/sessionsPartService.js'; import { AbstractCustomView } from '../../../customView/browser/customView.js'; import { CustomViewService, ICustomViewService } from '../../../customView/browser/customViewService.js';-import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js';+import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { SessionsHasClosedItemContext } from '../../../../common/contextkeys.js'; import { COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js';@@ -180,7 +180,7 @@ class TestSessionsProvidersService extends mock<ISessionsProvidersService>() { super(); } - override registerProvider(): never {+ override registerProvider(_provider: ISessionsProvider): IDisposable { throw new Error('not implemented'); } @@ -232,7 +232,7 @@ class TestSessionsProvider extends mock<ISessionsProvider>() { function createSessionsManagementService( session: ISession, disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>,- provider: ISessionsProvider | readonly ISessionsProvider[] = new TestSessionsProvider(session),+ provider: ISessionsProvider | readonly ISessionsProvider[] | TestSessionsProvidersService = new TestSessionsProvider(session), workspaceTrustManagementService = new TestWorkspaceTrustManagementService(), workspaceTrustRequestService?: IWorkspaceTrustRequestService, configurationService: IConfigurationService = new TestConfigurationService(),@@ -242,15 +242,15 @@ function createSessionsManagementService( instantiationService.stub(INotificationService, { error: message => notifications.push(message) }); const chatWidgetService = new TestChatWidgetService(); const chatService = disposables.add(new TestChatService());- const providers = Array.isArray(provider) ? provider : [provider];+ const providersService = provider instanceof TestSessionsProvidersService ? provider : new TestSessionsProvidersService(Array.isArray(provider) ? provider : [provider]); const contextKeyService = disposables.add(new MockContextKeyService()); const customViewService = disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService()))); instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IContextKeyService, contextKeyService);- instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService(providers));+ instantiationService.stub(ISessionsProvidersService, providersService); instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); instantiationService.stub(IChatWidgetService, chatWidgetService); instantiationService.stub(IProgressService, new TestProgressService());@@ -324,6 +324,313 @@ suite('SessionsManagementService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + suite('provider catalogue changes', () => {++ function createProvidersService(): TestSessionsProvidersService {+ const providers: ISessionsProvider[] = [];+ const onDidChangeProviders = disposables.add(new Emitter<ISessionsProvidersChangeEvent>());+ return new class extends TestSessionsProvidersService {+ override readonly onDidChangeProviders = onDidChangeProviders.event;+ override registerProvider(provider: ISessionsProvider): IDisposable {+ providers.push(provider);+ onDidChangeProviders.fire({ added: [provider], removed: [] });+ return toDisposable(() => {+ providers.splice(providers.indexOf(provider), 1);+ onDidChangeProviders.fire({ added: [], removed: [provider] });+ });+ }+ }(providers);+ }++ test('announces cached sessions when a provider registers', () => {+ const session = stubSession({ sessionId: 'cached', providerId: 'test' });+ const providersService = createProvidersService();+ const { service } = createSessionsManagementService(session, disposables, providersService);+ const changes: ISessionChangeEvent[] = [];+ let listed = service.getSessions();+ disposables.add(service.onDidChangeSessions(e => {+ changes.push(e);+ listed = service.getSessions();+ }));++ disposables.add(providersService.registerProvider(new TestSessionsProvider(session)));++ assert.deepStrictEqual({+ changes,+ listed,+ sameSession: service.getSession(session.resource) === session,+ }, {+ changes: [{ added: [session], removed: [], changed: [] }],+ listed: [session],+ sameSession: true,+ });+ });++ test('seeds cached sessions before subscribing to reentrant provider changes', () => {+ const session = stubSession({ sessionId: 'cached', providerId: 'test' });+ const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>());+ let reads = 0;+ const provider = new class extends TestSessionsProvider {+ override readonly onDidChangeSessions = onDidChangeSessions.event;+ override getSessions(): ISession[] {+ if (++reads === 1) {+ onDidChangeSessions.fire({ added: [session], removed: [], changed: [] });+ }+ return [session];+ }+ }(session);+ const providersService = createProvidersService();+ const { service } = createSessionsManagementService(session, disposables, providersService);+ const changes: ISessionChangeEvent[] = [];+ const snapshots: ISession[][] = [];+ disposables.add(service.onDidChangeSessions(e => {+ changes.push(e);+ snapshots.push(service.getSessions());+ }));++ disposables.add(providersService.registerProvider(provider));++ assert.deepStrictEqual({ changes, snapshots, reads }, {+ changes: [{ added: [session], removed: [], changed: [] }],+ snapshots: [[session]],+ reads: 2,+ });+ });++ test('refreshes the catalogue without deletions when a provider clears its cache and unregisters', () => {+ const session = stubSession({ sessionId: 'cached', providerId: 'test' });+ const sessions = [session];+ let reads = 0;+ const provider = new class extends TestSessionsProvider {+ override getSessions(): ISession[] {+ reads++;+ return sessions;+ }+ }(session);+ const providersService = createProvidersService();+ const registration = disposables.add(providersService.registerProvider(provider));+ const { service } = createSessionsManagementService(session, disposables, providersService);+ const changes: ISessionChangeEvent[] = [];+ disposables.add(service.onDidChangeSessions(e => changes.push(e)));+ const readsBeforeRemoval = reads;++ sessions.length = 0;+ registration.dispose();++ assert.deepStrictEqual({+ changes,+ listed: service.getSessions(),+ readsAfterRemoval: reads - readsBeforeRemoval,+ }, {+ changes: [{ added: [], removed: [], changed: [] }],+ listed: [],+ readsAfterRemoval: 0,+ });+ });++ test('does not subscribe to a provider removed while its cached sessions are read', () => {+ const session = stubSession({ sessionId: 'cached', providerId: 'test' });+ const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>());+ const provider = new class extends TestSessionsProvider {+ override readonly onDidChangeSessions = onDidChangeSessions.event;+ override getSessions(): ISession[] {+ registration.dispose();+ return [session];+ }+ }(session);+ const providersService = createProvidersService();+ const registration = disposables.add(providersService.registerProvider(provider));+ const { service } = createSessionsManagementService(session, disposables, providersService);+ const changes: ISessionChangeEvent[] = [];+ disposables.add(service.onDidChangeSessions(e => changes.push(e)));++ onDidChangeSessions.fire({ added: [session], removed: [], changed: [] });++ assert.deepStrictEqual({ changes, listed: service.getSessions(), listening: onDidChangeSessions.hasListeners() }, {+ changes: [],+ listed: [],+ listening: false,+ });+ });++ test('keeps session reads live and deduplicated across provider changes', () => {+ const legacy = stubSession({+ sessionId: 'legacy', providerId: 'test',+ resource: URI.from({ scheme: COPILOT_CLI_EH_SCHEME, path: '/shared' }),+ });+ const migrated = stubSession({+ sessionId: 'migrated', providerId: LOCAL_AGENT_HOST_PROVIDER_ID,+ resource: URI.from({ scheme: COPILOT_CLI_LOCAL_AH_SCHEME, path: '/shared' }),+ });+ const added = stubSession({ sessionId: 'added', providerId: LOCAL_AGENT_HOST_PROVIDER_ID });+ const sessions = [migrated];+ const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>());+ const provider = new class extends TestSessionsProvider {+ override readonly id = LOCAL_AGENT_HOST_PROVIDER_ID;+ override readonly onDidChangeSessions = onDidChangeSessions.event;+ override getSessions(): ISession[] { return sessions; }+ }(migrated);+ const providersService = createProvidersService();+ disposables.add(providersService.registerProvider(new TestSessionsProvider(legacy)));+ const { service } = createSessionsManagementService(legacy, disposables, providersService);+ const changes: ISessionChangeEvent[] = [];+ const snapshots: ISession[][] = [];+ disposables.add(service.onDidChangeSessions(e => {+ changes.push(e);+ snapshots.push(service.getSessions());+ }));++ const registration = disposables.add(providersService.registerProvider(provider));+ const sameLegacySession = service.getSession(legacy.resource) === legacy;+ const sameMigratedSession = service.getSession(migrated.resource) === migrated;+ sessions.push(added);+ onDidChangeSessions.fire({ added: [], removed: [], changed: [] });+ registration.dispose();++ assert.deepStrictEqual({ changes, snapshots, sameLegacySession, sameMigratedSession }, {+ changes: [+ { added: [migrated], removed: [], changed: [] },+ { added: [], removed: [], changed: [] },+ { added: [], removed: [], changed: [] },+ ],+ snapshots: [[migrated], [migrated, added], [legacy]],+ sameLegacySession: true,+ sameMigratedSession: true,+ });+ });++ test('forwards real removals and replacements without treating provider loss as deletion', () => {+ const removed = stubSession({ sessionId: 'removed', providerId: 'test' });+ const original = stubSession({ sessionId: 'original', providerId: 'test' });+ const changed = stubSession({ sessionId: 'original', providerId: 'test', title: constObservable('Changed') });+ const added = stubSession({ sessionId: 'added', providerId: 'test' });+ const replacement = stubSession({ sessionId: 'replacement', providerId: 'test' });+ const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>());+ const onDidReplaceSession = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>());+ let reads = 0;+ const provider = new class extends TestSessionsProvider {+ override readonly onDidChangeSessions = onDidChangeSessions.event;+ override readonly onDidReplaceSession = onDidReplaceSession.event;+ override getSessions(): ISession[] {+ reads++;+ return [removed, original];+ }+ }(original);+ const providersService = createProvidersService();+ const { service } = createSessionsManagementService(original, disposables, providersService);+ const registration = disposables.add(providersService.registerProvider(provider));+ const changes: ISessionChangeEvent[] = [];+ disposables.add(service.onDidChangeSessions(e => changes.push(e)));+ const readsBeforeChanges = reads;++ const change = { added: [added], removed: [removed], changed: [changed] };+ onDidChangeSessions.fire(change);+ onDidReplaceSession.fire({ from: added, to: replacement });+ registration.dispose();++ assert.deepStrictEqual({ changes, readsAfterChanges: reads - readsBeforeChanges }, {+ changes: [+ change,+ { added: [], removed: [added], changed: [replacement] },+ { added: [], removed: [], changed: [] },+ ],+ readsAfterChanges: 0,+ });+ });++ test('preserves visible sessions and pending drafts when their provider unregisters', async () => {+ const active = stubSession({ sessionId: 'active', providerId: 'test' });+ const draft = stubSession({ sessionId: 'draft', providerId: 'test' });+ const automation = stubSession({ sessionId: 'automation', providerId: 'test' });+ const fallback = stubSession({ sessionId: 'fallback', providerId: 'retained' });+ const folderUri = URI.parse('test:///folder');+ let draftsCreated = 0;+ const deleted: string[] = [];+ const provider = new class extends TestSessionsProvider {+ override readonly automations = upcastPartial<NonNullable<ISessionsProvider['automations']>>({+ catalogueState: constObservable('ready'),+ });+ override resolveWorkspace(): ISessionWorkspace {+ return { uri: folderUri, label: 'Folder', icon: Codicon.folder, folders: [], requiresWorkspaceTrust: false, isVirtualWorkspace: false };+ }+ override createNewSession(): ISession { return draftsCreated++ === 0 ? draft : automation; }+ override deleteNewSession(sessionId: string): void { deleted.push(sessionId); }+ }(active);+ const retainedProvider = new class extends TestSessionsProvider {+ override readonly id = 'retained';+ }(fallback);+ const providersService = createProvidersService();+ disposables.add(providersService.registerProvider(retainedProvider));+ const registration = disposables.add(providersService.registerProvider(provider));+ const { service, view } = createSessionsManagementService(active, disposables, providersService);+ await view.openSession(fallback.resource);+ await view.openSessionToSide(active);+ service.createNewSession(folderUri);+ service.createAutomationSession(folderUri);+ const changes: ISessionChangeEvent[] = [];+ disposables.add(service.onDidChangeSessions(e => changes.push(e)));++ registration.dispose();+ await timeout(0);++ assert.deepStrictEqual({+ changes,+ listed: service.getSessions(),+ draft: service.newSession.get(),+ automation: service.automationSession.get(),+ visible: view.visibleSessions.get().map(session => session?.sessionId),+ active: view.activeSession.get()?.sessionId,+ deleted,+ }, {+ changes: [{ added: [], removed: [], changed: [] }],+ listed: [fallback],+ draft,+ automation,+ visible: ['fallback', 'active'],+ active: 'active',+ deleted: [],+ });+ });++ test('ignores events from an unregistered provider after its ID is reused', () => {+ const original = stubSession({ sessionId: 'original', providerId: 'test' });+ const current = stubSession({ sessionId: 'current', providerId: 'test' });+ const stale = stubSession({ sessionId: 'stale', providerId: 'test' });+ const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>());+ const onDidReplaceSession = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>());+ const onDidChangeSessionTypes = disposables.add(new Emitter<void>());+ const provider = new class extends TestSessionsProvider {+ override readonly onDidChangeSessions = onDidChangeSessions.event;+ override readonly onDidReplaceSession = onDidReplaceSession.event;+ override readonly onDidChangeSessionTypes = onDidChangeSessionTypes.event;+ }(original);+ const providersService = createProvidersService();+ const { service } = createSessionsManagementService(original, disposables, providersService);+ const registration = disposables.add(providersService.registerProvider(provider));+ registration.dispose();+ const currentRegistration = disposables.add(providersService.registerProvider(new TestSessionsProvider(current)));+ const changes: ISessionChangeEvent[] = [];+ const replacements: { readonly from: ISession; readonly to: ISession }[] = [];+ let typeChanges = 0;+ disposables.add(service.onDidChangeSessions(e => changes.push(e)));+ disposables.add(service.onDidReplaceSession(e => replacements.push(e)));+ disposables.add(service.onDidChangeSessionTypes(() => typeChanges++));++ onDidChangeSessions.fire({ added: [stale], removed: [], changed: [] });+ onDidReplaceSession.fire({ from: original, to: stale });+ onDidChangeSessionTypes.fire();+ const listed = service.getSessions();+ currentRegistration.dispose();++ assert.deepStrictEqual({ changes, replacements, typeChanges, listed }, {+ changes: [{ added: [], removed: [], changed: [] }],+ replacements: [],+ typeChanges: 1,+ listed: [current],+ });+ });+ });+ test('activates an existing empty composer from part focus without replacing its sibling session', async () => { const session = stubSession({ sessionId: 'existing', providerId: 'test' }); const { view, focusSession } = createSessionsManagementService(session, disposables);