diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 07de3b0d5c169..8bdc6e7d05037 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -5253,6 +5253,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const removed: ISession[] = []; + this._onHostListedSessions(currentKeys); // Some hosts briefly omit the just-sent eager session from listSessions. // Keep the pending session visible until sendRequest graduates it. const pendingRawId = this._pendingSession?.resource.path.replace(/^\//, ''); @@ -5272,6 +5273,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (key === pendingRawId) { continue; } + if (!this._isSessionEvictable(key)) { + continue; + } if (!evictUnlistedAgents && !listedAgentProviders.has(cached.agentProvider)) { continue; } @@ -5304,6 +5308,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + /** + * Whether a cached session the host did not list may be evicted. Subclasses override this to + * protect a session that exists but that the host has not materialized yet. + */ + protected _isSessionEvictable(_rawId: string): boolean { + return true; + } + + /** Raw ids the host listed, reported before eviction runs so subclasses can retire protections. */ + protected _onHostListedSessions(_rawIds: ReadonlySet): void { } + /** * Arm a backoff retry of {@link _refreshSessions}. Used after a failed * refresh so a transient startup failure self-heals without requiring an diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 796f58e39ab30..3318c9cad038a 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -16,6 +16,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; import { getAgentSessionPullRequestUri, IAgentSession } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { getRepositoryName } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; @@ -55,7 +56,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { getWorkbenchContribution } from '../../../../../workbench/common/contributions.js'; -import { CloudSandboxAgentHostContribution } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; +import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -2103,11 +2104,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const placeholder = this._chatToSession(session); this._onDidChangeSessions.fire({ added: [placeholder], removed: [], changed: [] }); + let provisioned: ICloudSandboxProvisionedSession | undefined; try { - const provisioned = await this._getCloudSandboxContribution().provisionSession({ + provisioned = await this._getCloudSandboxContribution().provisionSession({ repoNwo, - // No `baseRef`: cloud sessions have no branch picker, so Mission Control picks the - // repository's default branch — the same branch the server-run cloud agent uses. + // No `baseRef`: cloud sessions have no branch picker; Mission Control chooses. prompt: options.query, }, CancellationToken.None); @@ -2116,14 +2117,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const chat = provisioned.session.mainChat.get(); const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); - this._sessionCache.delete(session.resource.toString()); - this._invalidateGroupingCaches(); - this._sessionGroupCache.delete(session.sessionId); - this._clearCurrentNewSessionIfMatch(session); - this._onDidReplaceSession.fire({ from: placeholder, to: committed }); + // Retire only once the turn is dispatched; swapping earlier bounces the view home. + this._publishSandboxSession(provisioned, { announce: false }); + this._retirePlaceholder(session, placeholder, committed); return committed; } catch (error) { this.logService.error(`[CopilotChatSessionsProvider] Failed to start cloud sandbox session for ${repoNwo}:`, error); + // The sandbox outlives a failed first turn, so list it rather than leaving it invisible. + if (provisioned) { + this._publishSandboxSession(provisioned); + } this._sessionCache.delete(session.resource.toString()); this._invalidateGroupingCaches(); this._sessionGroupCache.delete(session.sessionId); @@ -2134,6 +2137,20 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } + /** Reveal the sandbox session that {@link CloudSandboxAgentHostContribution.provisionSession} withheld from listings. */ + private _publishSandboxSession(provisioned: ICloudSandboxProvisionedSession, options?: { announce?: boolean }): void { + provisioned.provider.publishWithheldSession(AgentSession.id(provisioned.session.resource), options); + } + + /** Retire the optimistic placeholder in favour of the session that now exists. */ + private _retirePlaceholder(session: RemoteNewSession, placeholder: ISession, committed: ISession): void { + this._sessionCache.delete(session.resource.toString()); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(session.sessionId); + this._clearCurrentNewSessionIfMatch(session); + this._onDidReplaceSession.fire({ from: placeholder, to: committed }); + } + async sendRequest(sessionId: string, chatResource: URI, options: ISendRequestOptions): Promise { const newSession = this._newSessions.get(sessionId); if (newSession) { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 2b9f5154acc20..98acf549a1c21 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -39,7 +39,7 @@ import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, SessionSta import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; -import { RemoteAgentHostSessionsProvider } from '../../../remoteAgentHost/browser/remoteAgentHostSessionsProvider.js'; +import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/cloudSandboxSessionsProvider.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -1857,20 +1857,27 @@ suite('CopilotChatSessionsProvider', () => { } /** A provisioned session whose provider immediately commits the send. */ - function provisionedSession(): ICloudSandboxProvisionedSession { - const committed = upcastPartial({ sessionId: 'agenthost:sess-new' }); + function provisionedSession(sendRequest?: () => Promise): ICloudSandboxProvisionedSession & { published: string[] } { + const committed = upcastPartial({ + sessionId: 'agenthost:sess-new', + resource: URI.parse('agent-host-copilot:/sess-new'), + }); const sandboxSession = upcastPartial({ sessionId: 'agenthost:sess-new', + resource: URI.parse('agent-host-copilot:/sess-new'), mainChat: constObservable(upcastPartial({ resource: URI.parse('agent-host-copilot:/sess-new') })), }); + const published: string[] = []; return { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new', session: sandboxSession, - provider: upcastPartial({ - sendRequest: async () => committed, - }) as RemoteAgentHostSessionsProvider, + published, + provider: upcastPartial({ + sendRequest: sendRequest ?? (async () => committed), + publishWithheldSession: (rawId: string) => { published.push(rawId); }, + }) as CloudSandboxSessionsProvider, }; } @@ -1887,7 +1894,7 @@ suite('CopilotChatSessionsProvider', () => { assert.deepStrictEqual({ committed: committed.sessionId, - // The repo comes from the workspace root; no baseRef, so MC picks the default branch. + // The repo comes from the workspace root; no baseRef, matching the Copilot app. provisionRequests, // The prompt must not also go through the server-run cloud agent. cloudSends, @@ -1913,6 +1920,35 @@ suite('CopilotChatSessionsProvider', () => { assert.deepStrictEqual({ provisionRequests, cloudSends }, { provisionRequests: [], cloudSends: ['fix it'] }); }); + test('reveals the withheld sandbox session as it retires the placeholder', async () => { + const provisioned = provisionedSession(); + const { provider } = createSandboxProvider({ provision: async () => provisioned }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + + // The sandbox session is seeded before connecting so a discovery pass reconciles + // against it, but it must stay out of the list until the placeholder goes away — + // otherwise both rows show for as long as the sandbox takes to wake. + await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + + assert.deepStrictEqual(provisioned.published, ['sess-new']); + }); + + test('a failed send still reveals the sandbox session it already provisioned', async () => { + const provisioned = provisionedSession(async () => { throw new Error('send failed'); }); + const { provider } = createSandboxProvider({ provision: async () => provisioned }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + + await assert.rejects(() => provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' })); + + // The sandbox outlives the failed turn, so leaving it withheld would hide a session + // that really exists. + assert.deepStrictEqual(provisioned.published, ['sess-new']); + }); + test('a failed provision removes the placeholder instead of stranding it in the list', async () => { const { provider } = createSandboxProvider(); const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index 25ad9bc98d3ac..202a887c1c847 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ // Surfaces Copilot cloud sandbox (copilot-developer-cli) sessions as native agent-host sessions. -// Owns a RemoteAgentHostSessionsProvider per sandbox environment, connects on demand via +// Owns a CloudSandboxSessionsProvider per sandbox environment, connects on demand via // CloudSandboxAgentHostService, and wires the live connection to the provider so the native session // machinery can enumerate and render the host's sessions. @@ -44,7 +44,8 @@ import { CloudSandboxReadOnlySessionHandler } from './cloudSandboxReadOnlySessio import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionSchemeAlias, IRemoteAgentHostSessionsProviderConfig, RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; +import { ISessionSchemeAlias, IRemoteAgentHostSessionsProviderConfig } from './remoteAgentHostSessionsProvider.js'; +import { CloudSandboxSessionsProvider } from './cloudSandboxSessionsProvider.js'; import { IRemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js'; import { createCloudSandboxConnectionCustomization, isCloudSandboxConnectionAddress } from './cloudSandboxConnectionCustomization.js'; import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; @@ -91,7 +92,7 @@ function discoveredSessionProject(repoName: string | undefined): IAgentSessionMe * for the caller to send the first turn into it. */ export interface ICloudSandboxProvisionedSession extends ICloudSandboxCreatedSession { - readonly provider: RemoteAgentHostSessionsProvider; + readonly provider: CloudSandboxSessionsProvider; readonly session: ISession; } @@ -99,7 +100,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo static readonly ID = 'workbench.contrib.cloudSandboxAgentHost'; /** Provider instances keyed by connection address (`cloudsandbox:`). */ - private readonly _providerInstances = new Map(); + private readonly _providerInstances = new Map(); private readonly _providerStores = this._register(new DisposableMap()); /** Environment metadata keyed by connection address, for on-demand reconnect. */ private readonly _environments = new Map(); @@ -335,6 +336,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo throw new CancellationError(); } this._provisioning.add(address); + let seededProvider: CloudSandboxSessionsProvider | undefined; try { this._ensureProvider({ environmentId: created.environmentId, sessionId: created.sessionId, taskId: created.taskId, name }); @@ -344,7 +346,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo } const now = Date.now(); const project = discoveredSessionProject(request.repoNwo); - provider.seedSessions([{ + provider.seedProvisionalSession({ // Same identity discovery seeds under: Mission Control issues the session as // `ahp-session:/` and the host lists that id back, so this reconciles on connect. session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, created.sessionId), @@ -352,7 +354,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo modifiedTime: now, summary: name, ...(project ? { project } : {}), - }]); + }); + seededProvider = provider; await this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name }); @@ -364,12 +367,19 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo } // The adapter `seedSessions` created addresses the session by its raw id, which is the - // session id Mission Control just returned. - const session = provider.getSessions().find(candidate => AgentSession.id(candidate.resource) === created.sessionId); + // session id Mission Control just returned, and `getSessions` withholds it until + // the caller publishes it. + const session = provider.getCachedSession(created.sessionId); if (!session) { throw new Error(`Provisioned sandbox session ${created.sessionId} did not surface on its provider`); } return { ...created, provider, session }; + } catch (error) { + // The task exists remotely, and nothing else clears a withheld seed. + if (seededProvider && this._providerInstances.get(address) === seededProvider) { + seededProvider.publishWithheldSession(created.sessionId); + } + throw error; } finally { this._provisioning.delete(address); } @@ -666,8 +676,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo /** * Provider construction seam so tests can observe each provider's configuration. */ - protected _instantiateProvider(config: IRemoteAgentHostSessionsProviderConfig): RemoteAgentHostSessionsProvider { - return this._instantiationService.createInstance(RemoteAgentHostSessionsProvider, config); + protected _instantiateProvider(config: IRemoteAgentHostSessionsProviderConfig): CloudSandboxSessionsProvider { + return this._instantiationService.createInstance(CloudSandboxSessionsProvider, config); } /** Wire each live connection to its provider so session enumeration runs. */ diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts new file mode 100644 index 0000000000000..f226e0cda678a --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; +import type { ISession } from '../../../../services/sessions/common/session.js'; +import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; + +/** + * Sessions provider for a Copilot cloud sandbox. + * + * Adds the handling for sessions this client provisioned but the host has not materialized yet: + * Mission Control mints the session id and returns it before the sandbox is even awake, so such a + * session is real, addressable, and unknown to the host all at once. + */ +export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvider { + + /** + * Provisional sessions kept out of {@link getSessions} because the caller is still showing a + * placeholder row for them. They stay reachable by resource, so opening one still works. + */ + private readonly _withheldSessions = new Set(); + + /** + * Raw id → deadline after which eviction resumes, or `undefined` while the clock has not + * started. It starts when a connected host first omits the session, not at seed time, because + * waking a sandbox can take minutes. + */ + private readonly _provisionalSessions = new Map(); + + /** How long a provisional session resists eviction after the host first omits it. */ + static readonly PROVISIONAL_GRACE_MS = 2 * 60_000; + + /** + * Seed a session this client just provisioned. It is cached so a later discovery pass + * reconciles against it rather than adding a second entry, but stays out of the sessions list + * until {@link publishWithheldSession} and resists eviction until the host lists it. + */ + seedProvisionalSession(rawMeta: IAgentSessionMetadata): void { + const meta = this._adoptSessionMeta(rawMeta); + const rawId = AgentSession.id(meta.session); + if (this._sessionCache.has(rawId)) { + return; + } + this._sessionCache.set(rawId, this.createAdapter(meta)); + this._withheldSessions.add(rawId); + // No deadline yet: the clock starts when the host first omits it. + this._provisionalSessions.set(rawId, undefined); + } + + /** + * Reveal a session seeded by {@link seedProvisionalSession}, so {@link getSessions} returns it. + * + * Pass `announce: false` when the caller immediately fires its own change event covering this + * session: the list re-reads {@link getSessions} on any change, so a single event can both drop + * a placeholder row and reveal this one. + */ + publishWithheldSession(rawId: string, options?: { announce?: boolean }): void { + if (!this._withheldSessions.delete(rawId)) { + return; + } + const session = this._sessionCache.get(rawId); + if (session && options?.announce !== false) { + this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); + } + } + + /** + * Look up a cached session by raw id, **including** ones withheld from {@link getSessions}, + * which callers that seeded a session need before it is listed. + */ + getCachedSession(rawId: string): ISession | undefined { + return this._sessionCache.get(rawId); + } + + override getSessions(): ISession[] { + const sessions = super.getSessions(); + return this._withheldSessions.size === 0 + ? sessions + : sessions.filter(session => !this._withheldSessions.has(AgentSession.id(session.resource))); + } + + protected override _isSessionEvictable(rawId: string): boolean { + if (!this._provisionalSessions.has(rawId)) { + return true; + } + const deadline = this._provisionalSessions.get(rawId); + if (deadline === undefined || Date.now() < deadline) { + return false; + } + this._provisionalSessions.delete(rawId); + return true; + } + + protected override _onHostListedSessions(rawIds: ReadonlySet): void { + if (this._provisionalSessions.size === 0) { + return; + } + for (const [rawId, deadline] of [...this._provisionalSessions]) { + if (rawIds.has(rawId)) { + // The host knows it, so it reconciles like any other session from here on. + this._provisionalSessions.delete(rawId); + } else if (deadline === undefined) { + // Start the grace period now, so a slow wake does not consume it beforehand. + this._provisionalSessions.set(rawId, Date.now() + CloudSandboxSessionsProvider.PROVISIONAL_GRACE_MS); + } + } + } +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index 8f366d6ca3d6c..fdbcd3a674efb 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -37,10 +37,13 @@ import { ISessionsProvider } from '../../../../../services/sessions/common/sessi import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { CloudSandboxAgentHostContribution } from '../../browser/cloudSandboxAgentHostContribution.js'; import { IRemoteAgentHostConnectionCustomizationService } from '../../browser/remoteAgentHostConnectionCustomization.js'; -import { IRemoteAgentHostSessionsProviderConfig, RemoteAgentHostSessionsProvider } from '../../browser/remoteAgentHostSessionsProvider.js'; +import { IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; +import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; -class StubProvider extends mock() { +class StubProvider extends mock() { readonly seeded: IAgentSessionMetadata[] = []; + /** Raw ids seeded as provisional, mirroring the real provider's listing gate. */ + readonly withheld = new Set(); disposed = false; override readonly id: string; @@ -63,11 +66,35 @@ class StubProvider extends mock() { } } + override seedProvisionalSession(meta: IAgentSessionMetadata): void { + if (this.seeded.some(seen => seen.session.toString() === meta.session.toString())) { + return; + } + this.seeded.push(meta); + this.withheld.add(AgentSession.id(meta.session)); + } + /** Surfaces each seed under the UI resource scheme, which is what keys the raw session id. */ override getSessions(): ISession[] { - return this.seeded.map(meta => upcastPartial({ + return this.seeded + .filter(meta => !this.withheld.has(AgentSession.id(meta.session))) + .map(meta => this._toSession(meta)); + } + + /** Reaches withheld seeds too, which is the whole point of the cache accessor. */ + override getCachedSession(rawId: string): ISession | undefined { + const meta = this.seeded.find(seen => AgentSession.id(seen.session) === rawId); + return meta ? this._toSession(meta) : undefined; + } + + override publishWithheldSession(rawId: string): void { + this.withheld.delete(rawId); + } + + private _toSession(meta: IAgentSessionMetadata): ISession { + return upcastPartial({ resource: URI.from({ scheme: 'agent-host-copilot', path: `/${AgentSession.id(meta.session)}` }), - })); + }); } override setConnectionStatus(): void { } @@ -82,10 +109,10 @@ class StubProvider extends mock() { class TestCloudSandboxContribution extends CloudSandboxAgentHostContribution { readonly stubProviders = new Map(); - protected override _instantiateProvider(config: IRemoteAgentHostSessionsProviderConfig): RemoteAgentHostSessionsProvider { + protected override _instantiateProvider(config: IRemoteAgentHostSessionsProviderConfig): CloudSandboxSessionsProvider { const stub = new StubProvider(config); this.stubProviders.set(config.address, stub); - return stub as unknown as RemoteAgentHostSessionsProvider; + return stub as unknown as CloudSandboxSessionsProvider; } } @@ -282,6 +309,26 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { }); }); + test('publishes the seeded session when connecting fails, so it is not withheld forever', async () => { + // The task exists remotely once `createSession` returns, and nothing else clears a + // withheld seed. + const harness = await createContribution(store, []); + harness.onConnect = async () => { + throw new Error('relay unavailable'); + }; + + await assert.rejects(() => harness.contribution.provisionSession({ prompt: 'fix it' }, CancellationToken.None)); + + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); + assert.deepStrictEqual({ + withheld: [...(provider?.withheld ?? [])], + listed: provider?.getSessions().map(s => AgentSession.id(s.resource)), + }, { + withheld: [], + listed: ['sess-new'], + }); + }); + test('rejects when the feature is disabled while the sandbox is waking', async () => { // Connecting waits out the VM boot, which is long enough for the setting to change. // Returning a provider that teardown has already disposed would send into nothing. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index f2fa33ee6752a..35e75880ea092 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -33,8 +33,9 @@ import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; +import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IGitHubService } from '../../../../github/browser/githubService.js'; @@ -194,7 +195,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; ctor?: typeof RemoteAgentHostSessionsProvider }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -253,11 +254,12 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne workspaceTypeIcon: overrides?.workspaceTypeIcon, }; + const baseCtor = overrides?.ctor ?? RemoteAgentHostSessionsProvider; const providerCtor = overrides?.isWebPlatform !== undefined - ? class extends RemoteAgentHostSessionsProvider { + ? class extends baseCtor { protected override get isWebPlatform(): boolean { return overrides.isWebPlatform!; } } - : RemoteAgentHostSessionsProvider; + : baseCtor; const provider = disposables.add(instantiationService.createInstance(providerCtor, config)); if (!overrides?.noConnection) { provider.setConnection(connection); @@ -1380,3 +1382,172 @@ suite('RemoteAgentHostSessionsProvider', () => { })); }); + +suite('CloudSandboxSessionsProvider provisional sessions', () => { + + const disposables = new DisposableStore(); + let connection: MockAgentConnection; + + setup(() => { + connection = new MockAgentConnection(); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + /** The sandbox provider, built on the same mocks as the remote provider it extends. */ + function createSandboxProvider(store: DisposableStore, conn: MockAgentConnection, overrides?: { noConnection?: boolean; isWebPlatform?: boolean; omitHostFromWorkspaceLabel?: boolean }): CloudSandboxSessionsProvider { + return createProvider(store, conn, { ...overrides, ctor: CloudSandboxSessionsProvider }) as CloudSandboxSessionsProvider; + } + + /** Force a session refresh the way the host does: a turn-complete action on a known session. */ + async function refreshViaTurnComplete(connection: MockAgentConnection, rawId: string): Promise { + connection.fireAction({ + channel: buildDefaultChatUri(AgentSession.uri('copilotcli', rawId).toString()), + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-refresh', duration: 1 }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + await timeout(0); + } + + test('a provisional session survives a host listing that does not know it yet', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // The first listing after connecting can legitimately omit a just-minted session. + connection.addSession(createSession('other-1', { summary: 'Someone else' })); + const provider = createSandboxProvider(disposables, connection, { isWebPlatform: false, omitHostFromWorkspaceLabel: true }); + provider.seedProvisionalSession({ + session: AgentSession.uri('copilotcli', 'provisional-1'), + startTime: 0, + modifiedTime: 0, + summary: 'Just provisioned', + }); + provider.publishWithheldSession('provisional-1'); + + await timeout(0); + const survivedUnknown = provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)).sort(); + + // Once the host knows it, it reconciles like any other session. + connection.addSession(createSession('provisional-1', { summary: 'Just provisioned' })); + await refreshViaTurnComplete(connection, 'other-1'); + const afterHostKnows = provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)).sort(); + + assert.deepStrictEqual({ survivedUnknown, afterHostKnows }, { + survivedUnknown: ['other-1', 'provisional-1'], + afterHostKnows: ['other-1', 'provisional-1'], + }); + })); + + test('a provisional session the host never lists is evicted once its grace period ends', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('other-1', { summary: 'Someone else' })); + const provider = createSandboxProvider(disposables, connection, { isWebPlatform: false, omitHostFromWorkspaceLabel: true }); + provider.seedProvisionalSession({ + session: AgentSession.uri('copilotcli', 'never-listed'), + startTime: 0, + modifiedTime: 0, + summary: 'Never materialized', + }); + provider.publishWithheldSession('never-listed'); + await timeout(0); + + // The first listing that omits it starts the clock; it is still protected here. + await refreshViaTurnComplete(connection, 'other-1'); + const afterFirstOmission = provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)).sort(); + + // The protection is bounded so a session the host will never list cannot become a + // permanent row that only a reload clears. + await timeout(CloudSandboxSessionsProvider.PROVISIONAL_GRACE_MS + 1); + await refreshViaTurnComplete(connection, 'other-1'); + + assert.deepStrictEqual({ + afterFirstOmission, + afterGrace: provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)), + }, { + afterFirstOmission: ['never-listed', 'other-1'], + afterGrace: ['other-1'], + }); + })); + + test('a slow connection does not consume the grace period before the host answers', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Waking a sandbox can take minutes. If the clock ran from the seed, the first listing + // would meet an already-expired deadline and evict immediately — the disappearance this + // guard exists to prevent. + connection.addSession(createSession('other-1', { summary: 'Someone else' })); + // Seeded before connecting, exactly as provisioning does it: no listing can arrive until + // the sandbox is awake. + const provider = createSandboxProvider(disposables, connection, { noConnection: true, isWebPlatform: false, omitHostFromWorkspaceLabel: true }); + provider.seedProvisionalSession({ + session: AgentSession.uri('copilotcli', 'slow-wake'), + startTime: 0, + modifiedTime: 0, + summary: 'Slow to wake', + }); + provider.publishWithheldSession('slow-wake'); + + await timeout(CloudSandboxSessionsProvider.PROVISIONAL_GRACE_MS * 2); + provider.setConnection(connection); + await timeout(0); + + assert.deepStrictEqual(provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)).sort(), ['other-1', 'slow-wake']); + })); + + test('a withheld seed is cached and openable but stays out of the sessions list until published', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createSandboxProvider(disposables, new MockAgentConnection(), { noConnection: true, isWebPlatform: false, omitHostFromWorkspaceLabel: true }); + const announced: string[][] = []; + disposables.add(provider.onDidChangeSessions(e => announced.push(e.added.map(s => s.sessionId)))); + + provider.seedProvisionalSession({ + session: AgentSession.uri('copilotcli', 'withheld-1'), + startTime: 0, + modifiedTime: 0, + summary: 'Withheld Session', + }); + + const whileWithheld = { + listed: provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)), + // Reachable by id so the caller that seeded it can still act on it, and openable by + // resource so a swap into it does not land on a session the UI cannot resolve. + cached: AgentSession.id(provider.getCachedSession('withheld-1')!.resource), + announced: announced.length, + }; + + provider.publishWithheldSession('withheld-1'); + + assert.deepStrictEqual({ + whileWithheld, + listedAfterPublish: provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)), + announcedAfterPublish: announced, + }, { + whileWithheld: { listed: [], cached: 'withheld-1', announced: 0 }, + listedAfterPublish: ['withheld-1'], + announcedAfterPublish: [['agenthost-localhost__4321:remote-localhost__4321-copilotcli:/withheld-1']], + }); + })); + + test('publishing with announce:false lists the session without firing its own event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createSandboxProvider(disposables, new MockAgentConnection(), { noConnection: true, isWebPlatform: false, omitHostFromWorkspaceLabel: true }); + provider.seedProvisionalSession({ + session: AgentSession.uri('copilotcli', 'withheld-2'), + startTime: 0, + modifiedTime: 0, + summary: 'Withheld Session', + }); + + const announced: string[][] = []; + disposables.add(provider.onDidChangeSessions(e => announced.push(e.added.map(s => s.sessionId)))); + // The caller fires its own replace event covering this session, so a second event here + // would list the new row a frame before the placeholder row disappears. + provider.publishWithheldSession('withheld-2', { announce: false }); + + assert.deepStrictEqual({ + listed: provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)), + announced, + }, { + listed: ['withheld-2'], + announced: [], + }); + })); + +});