Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^\//, '');
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<string>): void { }

/**
* Arm a backoff retry of {@link _refreshSessions}. Used after a failed
* refresh so a transient startup failure self-heals without requiring an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -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<ISession> {
const newSession = this._newSessions.get(sessionId);
if (newSession) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1857,20 +1857,27 @@ suite('CopilotChatSessionsProvider', () => {
}

/** A provisioned session whose provider immediately commits the send. */
function provisionedSession(): ICloudSandboxProvisionedSession {
const committed = upcastPartial<ISession>({ sessionId: 'agenthost:sess-new' });
function provisionedSession(sendRequest?: () => Promise<ISession>): ICloudSandboxProvisionedSession & { published: string[] } {
const committed = upcastPartial<ISession>({
sessionId: 'agenthost:sess-new',
resource: URI.parse('agent-host-copilot:/sess-new'),
});
const sandboxSession = upcastPartial<ISession>({
sessionId: 'agenthost:sess-new',
resource: URI.parse('agent-host-copilot:/sess-new'),
mainChat: constObservable(upcastPartial<IChat>({ 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<RemoteAgentHostSessionsProvider>({
sendRequest: async () => committed,
}) as RemoteAgentHostSessionsProvider,
published,
provider: upcastPartial<CloudSandboxSessionsProvider>({
sendRequest: sendRequest ?? (async () => committed),
publishWithheldSession: (rawId: string) => { published.push(rawId); },
}) as CloudSandboxSessionsProvider,
};
}

Expand All @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -91,15 +92,15 @@ 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;
}

export class CloudSandboxAgentHostContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.cloudSandboxAgentHost';

/** Provider instances keyed by connection address (`cloudsandbox:<envId>`). */
private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();
private readonly _providerInstances = new Map<string, CloudSandboxSessionsProvider>();
private readonly _providerStores = this._register(new DisposableMap<string>());
/** Environment metadata keyed by connection address, for on-demand reconnect. */
private readonly _environments = new Map<string, ICloudSandboxEnvironment>();
Expand Down Expand Up @@ -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 });

Expand All @@ -344,15 +346,16 @@ 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:/<id>` and the host lists that id back, so this reconciles on connect.
session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, created.sessionId),
startTime: now,
modifiedTime: now,
summary: name,
...(project ? { project } : {}),
}]);
});
seededProvider = provider;

await this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name });
Comment thread
osortega marked this conversation as resolved.

Expand All @@ -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);
}
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading