microsoft/vscode · #337173

sessions: Prepare cloud sandbox repositories before session creation

osortega · merged Sep 21, 202611 files · 898 + / 8
src/vs/platform/agentHost/browser/agentHostProtocolClient.ts5 + / 0
@@ -2197,6 +2197,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect 		return this._dispatchRequest<IAgentHostExtensionCommandMap[M]['result']>(method, params); 	} +	/** Sends a host-specific extension request; its consumer must validate the response. */+	sendHostExtensionRequest(method: `extensions/${string}` | `x-${string}`, params: unknown): Promise<unknown> {+		return this._dispatchRequest(method, params);+	}+ 	private _updateTelemetryLevel(): void { 		this._dispatchRootConfig({ [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(this._effectiveTelemetryLevel()) }); 	}
src/vs/platform/agentHost/common/meta/cloudSandboxProjectMeta.tsadded62 + / 0
@@ -0,0 +1,62 @@+/*---------------------------------------------------------------------------------------------+ *  Copyright (c) Microsoft Corporation. All rights reserved.+ *  Licensed under the MIT License. See License.txt in the project root for license information.+ *--------------------------------------------------------------------------------------------*/++// TODO: Remove this compatibility file after adopting a protocol release containing https://github.com/microsoft/agent-host-protocol/pull/451.++import { isObject } from '../../../../base/common/types.js';+import type { RootState } from '../state/protocol/state.js';++export interface ICloudSandboxProject {+	readonly id: string;+	readonly path: string;+	readonly git: boolean;+	readonly status: 'ready' | 'cloning' | 'failed';+	readonly remoteUrl?: string;+	readonly error?: string;+}++export function readCloudSandboxCloneResult(result: unknown): ICloudSandboxProject | undefined {+	return isRecord(result) ? readProject(result.project) : undefined;+}++function readProject(value: unknown): ICloudSandboxProject | undefined {+	if (!isRecord(value)+		|| typeof value.id !== 'string' || !value.id+		|| typeof value.path !== 'string' || !value.path+		|| typeof value.git !== 'boolean') {+		return undefined;+	}+	const status = value.status === undefined ? 'ready' : value.status;+	if (status !== 'ready' && status !== 'cloning' && status !== 'failed') {+		return undefined;+	}+	return {+		id: value.id,+		path: value.path,+		git: value.git,+		status,+		remoteUrl: typeof value.remoteUrl === 'string' ? value.remoteUrl : undefined,+		error: typeof value.error === 'string' ? value.error : undefined,+	};+}++export function readCloudSandboxProjects(state: RootState): readonly ICloudSandboxProject[] | undefined {+	const capability = state._meta?.['copilot.projectManagement'];+	if (!isRecord(capability) || capability.available !== true) {+		return undefined;+	}+	const copilot = state.config?.values?.copilot;+	if (!isRecord(copilot) || !Array.isArray(copilot.projects)) {+		return [];+	}+	return copilot.projects.flatMap(value => {+		const project = readProject(value);+		return project ? [project] : [];+	});+}++function isRecord(value: unknown): value is Record<string, unknown> {+	return isObject(value);+}
src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts18 + / 0
@@ -1824,6 +1824,24 @@ suite('AgentHostProtocolClient', () => { 		}); 	}); +	test('sendHostExtensionRequest uses normal request correlation', async () => {+		const { client, transport } = createClient();+		const params = { url: 'https://github.com/microsoft/vscode', depth: 1 };+		const result = { project: { id: 'checkout', status: 'cloning' } };+		const request = client.sendHostExtensionRequest('extensions/cloneProject', params);+		assert.deepStrictEqual(transport.sentMessages[0], { jsonrpc: '2.0', id: 1, method: 'extensions/cloneProject', params });+		transport.fireMessage({ jsonrpc: '2.0', id: 1, result });+		assert.deepStrictEqual(await request, result);+	});++	test('sendHostExtensionRequest propagates unsupported host errors', async () => {+		const { client, transport } = createClient();+		const request = client.sendHostExtensionRequest('extensions/cloneProject', { url: 'https://github.com/microsoft/vscode', depth: 1 });+		const error = { code: JsonRpcErrorCodes.MethodNotFound, message: 'Method not found' };+		transport.fireMessage({ jsonrpc: '2.0', id: 1, error });+		await assertRemoteProtocolError(request, error);+	});+ 	test('removeSessionArtifact sends the VS Code extension request', async () => { 		const { client, transport } = createClient(); 		const session = URI.parse('copilotcli:/session-1');
src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md2 + / 0
@@ -67,6 +67,8 @@ Remote session and chat resources preserve connection-specific routing identity  For cloud sandbox sessions, archive and unarchive update the client session cache without requiring a live host. Host refreshes preserve the cached archive flag; cross-client archive synchronization is not yet supported. +For cloud sandboxes advertising project management, the connection customization owns a temporary session-start callback that resolves the selected repository to a ready host directory before creation and customization binding. Preparation errors stop creation; cancelling the client wait does not remove the host's checkout. Hosts without this capability retain their existing directory handling. This compatibility path does not depend on a draft protocol shape and can be removed after adopting released repository-backed creation ([proposal](https://github.com/microsoft/agent-host-protocol/pull/451)).+ ## Authentication and recovery  Authentication challenges, credential refresh, and transport retries remain connection policy. The request that encountered a challenge observes its actual success, cancellation, or failure; provider operations do not silently convert authentication failures into availability results.
src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxConnectionCustomization.ts18 + / 1
@@ -3,6 +3,9 @@  *  Licensed under the MIT License. See License.txt in the project root for license information.  *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js';+import { DisposableStore } from '../../../../../base/common/lifecycle.js';+import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { 	CLOUD_SANDBOX_ADDRESS_PREFIX, 	CLOUD_SANDBOX_AGENT_PROVIDER,@@ -13,6 +16,7 @@ import { } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IAgentHostAuthenticateRequest } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { IRemoteAgentHostConnectionCustomization } from './remoteAgentHostConnectionCustomization.js';+import { createCloudSandboxSessionPreparation } from './cloudSandboxLegacySessionPreparation.js';  /** Hosts whose protected resources may receive the user's GitHub identity token. */ function isGitHubResource(resource: string): boolean {@@ -30,12 +34,13 @@ function isGitHubResource(resource: string): boolean { }  /**- * The {@link IRemoteAgentHostConnectionCustomization} for a cloud sandbox address, supplying the two+ * The {@link IRemoteAgentHostConnectionCustomization} for a cloud sandbox address, supplying the  * ways the sandbox host deviates from the generic path:  *  *  - **Auth**: the host only accepts a sealed envelope, so the connection's `encrypted_github_token`  *    is presented instead of the resolved bearer. Fails closed if no sealed token is available.  *  - **Scheme**: the host advertises provider `copilot` but addresses sessions as `ahp-session`.+ *  - **Directory**: prepare an advertised repository checkout before creating a session.  *  * Returns `undefined` for non-sandbox addresses.  */@@ -66,6 +71,18 @@ export function createCloudSandboxConnectionCustomization( 		}, 		backendSessionScheme: (provider: string): string | undefined => 			provider === CLOUD_SANDBOX_AGENT_PROVIDER ? CLOUD_SANDBOX_SESSION_SCHEME : undefined,+		createSessionPreparation: (connection, owner) => {+			if (!(connection instanceof AgentHostProtocolClient)) {+				throw new Error('Cloud sandbox session preparation requires a protocol client.');+			}+			const store = owner.add(new DisposableStore());+			store.add(Event.once(connection.onDidClose)(() => store.dispose()));+			return createCloudSandboxSessionPreparation(+				connection.rootState,+				(method, params) => connection.sendHostExtensionRequest(method, params),+				store,+			);+		}, 	}; } 
src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxLegacySessionPreparation.tsadded161 + / 0
@@ -0,0 +1,161 @@+/*---------------------------------------------------------------------------------------------+ *  Copyright (c) Microsoft Corporation. All rights reserved.+ *  Licensed under the MIT License. See License.txt in the project root for license information.+ *--------------------------------------------------------------------------------------------*/++// TODO: Remove this compatibility file after adopting a protocol release containing https://github.com/microsoft/agent-host-protocol/pull/451.++import { disposableTimeout, raceCancellationError } from '../../../../../base/common/async.js';+import { CancellationToken, CancellationTokenPool, cancelOnDispose } from '../../../../../base/common/cancellation.js';+import { CancellationError } from '../../../../../base/common/errors.js';+import { DisposableStore } from '../../../../../base/common/lifecycle.js';+import { Schemas } from '../../../../../base/common/network.js';+import { equals } from '../../../../../base/common/objects.js';+import { equalsIgnoreCase } from '../../../../../base/common/strings.js';+import { URI } from '../../../../../base/common/uri.js';+import { localize } from '../../../../../nls.js';+import { ICloudSandboxProject, readCloudSandboxCloneResult, readCloudSandboxProjects } from '../../../../../platform/agentHost/common/meta/cloudSandboxProjectMeta.js';+import { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js';+import { RootState } from '../../../../../platform/agentHost/common/state/protocol/state.js';+import { getGitHubRepositoryFromRemoteUrl, IGitHubRemoteInfo } from '../../../../../workbench/contrib/git/common/utils.js';+import { RemoteAgentHostSessionPreparation } from './remoteAgentHostConnectionCustomization.js';++export function createCloudSandboxSessionPreparation(+	root: IAgentSubscription<RootState>,+	request: (method: 'extensions/cloneProject', params: { url: string; depth: 1 }) => Promise<unknown>,+	owner: DisposableStore,+): RemoteAgentHostSessionPreparation {+	const lifetime = cancelOnDispose(owner);+	const preparations = new Map<string, { readonly promise: Promise<URI>; readonly cancellation: CancellationTokenPool }>();++	return async (selection, token) => {+		if (token.isCancellationRequested || lifetime.isCancellationRequested) {+			throw new CancellationError();+		}+		if (!selection || selection.scheme !== Schemas.https) {+			return undefined;+		}+		const state = root.value;+		if (state instanceof Error) {+			throw state;+		}+		if (!state) {+			throw new Error(localize('sandbox.projectStateUnavailable', "The cloud sandbox's project state is unavailable."));+		}+		const projects = readCloudSandboxProjects(state);+		if (!projects) {+			return undefined;+		}+		const repository = getGitHubRepositoryFromRemoteUrl(selection.toString(), ['github.com']);+		if (!repository || !equalsIgnoreCase(selection.authority, 'github.com') || selection.query || selection.fragment) {+			throw new Error(localize('sandbox.invalidRepository', "The cloud sandbox requires a GitHub repository URL without credentials, a query, or a fragment."));+		}+		const key = `${repository.owner.toLowerCase()}/${repository.repo.toLowerCase()}`;+		let preparation = preparations.get(key);+		if (!preparation || preparation.cancellation.token.isCancellationRequested) {+			const cancellation = new CancellationTokenPool();+			const promise = prepareRepository(repository, projects, cancellation.token).finally(() => {+				if (preparations.get(key)?.promise === promise) {+					preparations.delete(key);+				}+				cancellation.dispose();+			});+			preparation = { promise, cancellation };+			preparations.set(key, preparation);+		}+		preparation.cancellation.add(token);+		return raceCancellationError(preparation.promise, token);+	};++	async function prepareRepository(repository: IGitHubRemoteInfo, projects: readonly ICloudSandboxProject[], token: CancellationToken): Promise<URI> {+		const matchesRepository = (project: ICloudSandboxProject): boolean => {+			const remote = project.remoteUrl && getGitHubRepositoryFromRemoteUrl(project.remoteUrl, ['github.com']);+			return !!remote && equalsIgnoreCase(remote.owner, repository.owner) && equalsIgnoreCase(remote.repo, repository.repo);+		};+		const matching = projects.filter(matchesRepository);+		const existing = matching.find(project => project.status === 'ready') ?? matching.find(project => project.status === 'cloning');+		const store = new DisposableStore();+		try {+			return await new Promise<URI>((resolve, reject) => {+				let projectId = existing?.id;+				// A retry acknowledgement can arrive before the failed catalogue entry is replaced.+				let staleFailure = matching.find(project => project.status === 'failed');+				const seen = new Set(projects.map(project => project.id));+				const update = () => {+					if (store.isDisposed) {+						return;+					}+					const state = root.value;+					if (state instanceof Error) {+						reject(state);+						return;+					}+					const current = state && readCloudSandboxProjects(state);+					if (!current) {+						reject(new Error(localize('sandbox.projectManagementUnavailable', "The cloud sandbox no longer advertises repository preparation.")));+						return;+					}+					if (staleFailure && !equals(staleFailure, current.find(project => project.id === staleFailure?.id))) {+						staleFailure = undefined;+					}+					const project = current.find(project => project.id === projectId);+					if (projectId && !project && seen.has(projectId)) {+						reject(new Error(localize('sandbox.projectRemoved', "The repository was removed from the cloud sandbox while it was being prepared.")));+					}+					for (const entry of current) {+						seen.add(entry.id);+					}+					if (!project) {+						return;+					}+					if (!matchesRepository(project)) {+						reject(new Error(localize('sandbox.projectChanged', "The cloud sandbox returned a different repository.")));+					} else if (project.status === 'failed' && !equals(project, staleFailure)) {+						reject(getCloneError(project));+					} else if (project.status === 'ready') {+						if (!project.git || !project.path.startsWith('/') || project.path.includes('\0')) {+							reject(new Error(localize('sandbox.invalidProjectDirectory', "The cloud sandbox did not return a usable repository directory.")));+						} else {+							resolve(URI.file(project.path));+						}+					}+				};+				store.add(root.onDidChange(update));+				if (root.onDidError) {+					store.add(root.onDidError(reject));+				}+				store.add(token.onCancellationRequested(() => reject(new CancellationError())));+				store.add(lifetime.onCancellationRequested(() => reject(new CancellationError())));+				store.add(disposableTimeout(() => reject(new Error(localize('sandbox.projectTimedOut', "Repository cloning did not finish within five minutes."))), 5 * 60_000));+				update();+				if (!projectId) {+					const url = URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${repository.owner}/${repository.repo}` }).toString();+					request('extensions/cloneProject', { url, depth: 1 }).then(result => {+						if (store.isDisposed) {+							return;+						}+						const project = readCloudSandboxCloneResult(result);+						if (!project || (project.status !== 'failed' && !matchesRepository(project))) {+							reject(new Error(localize('sandbox.invalidCloneResult', "The cloud sandbox returned an invalid repository cloning response.")));+							return;+						}+						if (project.status === 'failed') {+							reject(getCloneError(project));+							return;+						}+						projectId = project.id;+						update();+					}, reject);+				}+			});+		} finally {+			store.dispose();+		}+	}+}++function getCloneError(project: ICloudSandboxProject): Error {+	return new Error(project.error+		? localize('sandbox.projectFailedWithReason', "Repository cloning failed: {0}", project.error)+		: localize('sandbox.projectFailed', "Repository cloning failed."));+}
src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts10 + / 1
@@ -43,7 +43,7 @@ import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, import { createRemoteAgentHarnessDescriptor, RemoteAgentPluginController } from './remoteAgentHostCustomizationHarness.js'; import { RemoteAgentHostLogForwarder } from './remoteAgentHostLogForwarder.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';-import { IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js';+import { IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnectionCustomizationService, RemoteAgentHostSessionPreparation } from './remoteAgentHostConnectionCustomization.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js';@@ -118,6 +118,7 @@ class ConnectionState extends Disposable { 	/** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ 	readonly authTokenCache = new AgentHostAuthTokenCache(); 	readonly authRecovery: AgentHostAuthenticationRecovery;+	prepareSession: RemoteAgentHostSessionPreparation | undefined;  	constructor( 		readonly name: string | undefined,@@ -254,6 +255,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc 		const connState = this._instantiationService.createInstance(ConnectionState, name, connection); 		this._connections.set(address, connState); 		const store = connState.store;+		connState.prepareSession = this._connectionCustomizations.get(address)?.createSessionPreparation?.(connection, store);  		// Bridge the host's OTLP logs channel into a dedicated workbench 		// Output channel (`Agent Host (${name})`). Concrete clients@@ -341,6 +343,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc 		// Per-agent working directory cache, scoped to the agent store lifetime 		const sessionWorkingDirs = new Map<string, URI>(); 		agentStore.add(toDisposable(() => sessionWorkingDirs.clear()));+		const prepareSession = connState.prepareSession;  		// Capture the working directory from the session that is being created. 		const resolveWorkingDirectory = (sessionResource: URI): URI | undefined => {@@ -431,6 +434,12 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc 			extensionId: 'vscode.remote-agent-host', 			extensionDisplayName: 'Remote Agent Host', 			resolveWorkingDirectory,+			prepareSession: prepareSession ? async (sessionResource, token) => {+				const directory = await prepareSession(resolveWorkingDirectory(sessionResource), token);+				if (directory) {+					sessionWorkingDirs.set(sessionResource.toString(), connection.resourceUris.fromAgentHost(directory));+				}+			} : undefined, 			isNewSession, 			resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(address, connection, resources), 		}));
src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostConnectionCustomization.ts10 + / 1
@@ -3,10 +3,16 @@  *  Licensed under the MIT License. See License.txt in the project root for license information.  *--------------------------------------------------------------------------------------------*/ -import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';+import { CancellationToken } from '../../../../../base/common/cancellation.js';+import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';+import { URI } from '../../../../../base/common/uri.js';+import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAgentHostAuthenticateRequest } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; +/** Optional startup preparation for a selected workspace, returning a replacement host directory when needed. */+export type RemoteAgentHostSessionPreparation = (selection: URI | undefined, token: CancellationToken) => Promise<URI | undefined>;+ /**  * Per-connection behavior a specific remote-agent-host kind can inject into the otherwise  * host-agnostic remote-agent-host contribution. Connections with no customization are unaffected.@@ -23,6 +29,9 @@ export interface IRemoteAgentHostConnectionCustomization { 	 * Return `undefined` to keep scheme == provider. 	 */ 	readonly backendSessionScheme?: (provider: string) => string | undefined;++	/** Creates connection-scoped startup preparation, invoked only for new sessions after authentication. */+	readonly createSessionPreparation?: (connection: IAgentConnection, store: DisposableStore) => RemoteAgentHostSessionPreparation; }  /** Builds a {@link IRemoteAgentHostConnectionCustomization} for a concrete connection address. */
src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxLegacySessionPreparation.test.tsadded456 + / 0
@@ -0,0 +1,456 @@+/*---------------------------------------------------------------------------------------------+ *  Copyright (c) Microsoft Corporation. All rights reserved.+ *  Licensed under the MIT License. See License.txt in the project root for license information.+ *--------------------------------------------------------------------------------------------*/++// TODO: Remove this compatibility file after adopting a protocol release containing https://github.com/microsoft/agent-host-protocol/pull/451.++import assert from 'assert';+import sinon from 'sinon';+import { DeferredPromise, timeout } from '../../../../../../base/common/async.js';+import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';+import { isCancellationError } from '../../../../../../base/common/errors.js';+import { Emitter } from '../../../../../../base/common/event.js';+import { DisposableStore } from '../../../../../../base/common/lifecycle.js';+import { URI } from '../../../../../../base/common/uri.js';+import { mock } from '../../../../../../base/test/common/mock.js';+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';+import { AgentHostProtocolClient } from '../../../../../../platform/agentHost/browser/agentHostProtocolClient.js';+import { cloudSandboxAddress, ICloudSandboxAgentHostService } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js';+import { ICloudSandboxProject } from '../../../../../../platform/agentHost/common/meta/cloudSandboxProjectMeta.js';+import { RootStateSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js';+import { RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js';+import { ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js';+import { ROOT_STATE_URI } from '../../../../../../platform/agentHost/common/state/sessionState.js';+import { createCloudSandboxSessionPreparation } from '../../browser/cloudSandboxLegacySessionPreparation.js';+import { createCloudSandboxConnectionCustomization } from '../../browser/cloudSandboxConnectionCustomization.js';++const repository = URI.parse('https://github.com/microsoft/vscode');++function project(overrides: Partial<ICloudSandboxProject> = {}): ICloudSandboxProject {+	return { id: 'checkout', path: '/workspaces/vscode', git: true, status: 'ready', remoteUrl: repository.toString(), ...overrides };+}++function rootState(projects: readonly unknown[], capability: unknown = { available: true }): RootState {+	return {+		agents: [],+		_meta: { 'copilot.projectManagement': capability },+		config: { schema: { type: 'object', properties: {} }, values: { copilot: { projects } } },+	};+}++function createPreparation(store: Pick<DisposableStore, 'add'>, projects: readonly unknown[] = [], capability?: unknown) {+	const lifetime = store.add(new DisposableStore());+	const root = store.add(new RootStateSubscription('test-client', () => { }));+	root.handleSnapshot(rootState(projects, capability), 0);+	const reply = new DeferredPromise<unknown>();+	const replies: DeferredPromise<unknown>[] = [];+	const requests: { method: string; params: { url: string; depth: number } }[] = [];+	const prepareSession = createCloudSandboxSessionPreparation(root, (method, params) => {+		const response = replies.length === 0 ? reply : new DeferredPromise<unknown>();+		replies.push(response);+		requests.push({ method, params });+		return response.p;+	}, lifetime);+	return { root, prepareSession, lifetime, reply, replies, requests };+}++suite('CloudSandboxLegacySessionPreparation', () => {+	const store = ensureNoDisposablesAreLeakedInTestSuite();++	teardown(() => sinon.restore());++	test('installs startup preparation only for cloud sandbox connections', () => {+		const service = new class extends mock<ICloudSandboxAgentHostService>() { }();+		assert.deepStrictEqual({+			ordinary: createCloudSandboxConnectionCustomization('localhost:8080', service),+			sandbox: typeof createCloudSandboxConnectionCustomization(cloudSandboxAddress('test'), service)?.createSessionPreparation,+		}, { ordinary: undefined, sandbox: 'function' });+	});++	test('binds preparation to its protocol connection and releases it when that connection closes', async () => {+		const root = store.add(new RootStateSubscription('test-client', () => { }));+		root.handleSnapshot(rootState([]), 0);+		const closed = store.add(new Emitter<void>());+		const connection = sinon.createStubInstance(AgentHostProtocolClient);+		Object.defineProperties(connection, {+			rootState: { value: root },+			onDidClose: { value: closed.event },+		});+		const reply = new DeferredPromise<unknown>();+		connection.sendHostExtensionRequest.returns(reply.p);+		const service = new class extends mock<ICloudSandboxAgentHostService>() { }();+		const customization = createCloudSandboxConnectionCustomization(cloudSandboxAddress('test'), service);+		const owner = store.add(new DisposableStore());+		assert.ok(customization?.createSessionPreparation);+		assert.ok(connection instanceof AgentHostProtocolClient);+		const prepareSession = customization.createSessionPreparation(connection, owner);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), isCancellationError);+		closed.fire();+		await rejected;+		assert.deepStrictEqual({+			request: connection.sendHostExtensionRequest.firstCall.args,+			ownerDisposed: owner.isDisposed,+			closeListener: closed.hasListeners(),+		}, {+			request: ['extensions/cloneProject', { url: repository.toString(), depth: 1 }],+			ownerDisposed: false,+			closeListener: false,+		});+	});++	test('requires an explicitly advertised capability', async () => {+		const results = [];+		for (const capability of [null, false, true, {}, { available: false }, { available: 'true' }, { available: 1 }]) {+			const { prepareSession, requests } = createPreparation(store, [project()], capability);+			results.push({ directory: await prepareSession(repository, CancellationToken.None), requests });+		}+		assert.deepStrictEqual(results, Array.from({ length: 7 }, () => ({ directory: undefined, requests: [] })));+	});++	test('preserves the legacy path when metadata is absent', async () => {+		const { prepareSession, root, requests } = createPreparation(store);+		root.handleSnapshot({ agents: [] }, 0);+		assert.deepStrictEqual({ directory: await prepareSession(repository, CancellationToken.None), requests }, { directory: undefined, requests: [] });+	});++	test('preserves directory and repository-less creation without cloning', async () => {+		const { prepareSession, requests } = createPreparation(store);+		const results = [];+		for (const directory of [undefined, URI.file('/existing'), URI.parse('vscode-agent-host://host/existing')]) {+			results.push(await prepareSession(directory, CancellationToken.None));+		}+		assert.deepStrictEqual({ results, requests }, { results: [undefined, undefined, undefined], requests: [] });+	});++	test('reuses matching ready Git projects and tolerates unrelated malformed entries', async () => {+		const { prepareSession, requests } = createPreparation(store, [+			null, {}, project({ status: 'failed' }),+			project({ id: 'other', remoteUrl: 'https://github.com/another/repo' }),+			project({ id: 'ready', remoteUrl: 'git@github.com:Microsoft/VSCode.git' }),+		]);+		const directory = await prepareSession(repository, CancellationToken.None);+		assert.deepStrictEqual({ directory: directory?.toString(), requests }, { directory: 'file:///workspaces/vscode', requests: [] });+	});++	test('accepts a legacy project with no status as ready', async () => {+		const { status: _status, ...legacy } = project();+		const { prepareSession, requests } = createPreparation(store, [legacy]);+		assert.deepStrictEqual({ directory: (await prepareSession(repository, CancellationToken.None))?.toString(), requests }, {+			directory: 'file:///workspaces/vscode', requests: [],+		});+	});++	test('clones once and waits for the checkout to be published as ready', async () => {+		const { prepareSession, root, reply, requests } = createPreparation(store);+		let settled = false;+		const preparation = prepareSession(repository, CancellationToken.None).then(result => {+			settled = true;+			return result;+		});+		root.handleSnapshot(rootState([project({ status: 'cloning', git: false })]), 1);+		await reply.complete({ project: project({ status: 'cloning', git: false }) });+		await timeout(0);+		assert.strictEqual(settled, false);+		root.receiveEnvelope({+			channel: ROOT_STATE_URI,+			action: { type: ActionType.RootConfigChanged, config: { copilot: { projects: [project()] } } },+			serverSeq: 2,+			origin: undefined,+		});+		assert.deepStrictEqual({ directory: (await preparation)?.toString(), requests }, {+			directory: 'file:///workspaces/vscode',+			requests: [{ method: 'extensions/cloneProject', params: { url: repository.toString(), depth: 1 } }],+		});+	});++	test('joins an in-progress clone from another client', async () => {+		const { prepareSession, root, requests } = createPreparation(store, [project({ status: 'cloning', git: false })]);+		const preparation = prepareSession(repository, CancellationToken.None);+		root.handleSnapshot(rootState([project()]), 1);+		assert.deepStrictEqual({ directory: (await preparation)?.toString(), requests }, { directory: 'file:///workspaces/vscode', requests: [] });+	});++	test('shares concurrent cloning before any catalogue publication using normalized repository identity', async () => {+		const { prepareSession, root, reply, requests } = createPreparation(store);+		const first = prepareSession(repository, CancellationToken.None);+		const second = prepareSession(URI.parse('https://GITHUB.com/Microsoft/VSCode.git/'), CancellationToken.None);+		await reply.complete({ project: project({ status: 'cloning' }) });+		root.handleSnapshot(rootState([project()]), 1);+		assert.deepStrictEqual({+			directories: (await Promise.all([first, second])).map(directory => directory?.toString()),+			requests,+		}, {+			directories: ['file:///workspaces/vscode', 'file:///workspaces/vscode'],+			requests: [{ method: 'extensions/cloneProject', params: { url: repository.toString(), depth: 1 } }],+		});+	});++	test('keeps different repositories and connections independent', async () => {+		const firstConnection = createPreparation(store);+		const secondConnection = createPreparation(store);+		const otherRepository = URI.parse('https://github.com/microsoft/typescript');+		const otherProject = project({ id: 'other', remoteUrl: otherRepository.toString(), path: '/workspaces/typescript' });+		const preparations = [+			firstConnection.prepareSession(repository, CancellationToken.None),+			firstConnection.prepareSession(otherRepository, CancellationToken.None),+			secondConnection.prepareSession(repository, CancellationToken.None),+		];+		await firstConnection.replies[0].complete({ project: project() });+		await firstConnection.replies[1].complete({ project: otherProject });+		await secondConnection.reply.complete({ project: project() });+		firstConnection.root.handleSnapshot(rootState([project(), otherProject]), 1);+		secondConnection.root.handleSnapshot(rootState([project()]), 1);+		assert.deepStrictEqual({+			directories: (await Promise.all(preparations)).map(directory => directory?.toString()),+			calls: [firstConnection.requests.length, secondConnection.requests.length],+		}, {+			directories: ['file:///workspaces/vscode', 'file:///workspaces/typescript', 'file:///workspaces/vscode'],+			calls: [2, 1],+		});+	});++	test('one cancelled waiter does not cancel preparation for the remaining waiter', async () => {+		const { prepareSession, root, reply, requests } = createPreparation(store);+		const cancellation = store.add(new CancellationTokenSource());+		const cancelled = assert.rejects(prepareSession(repository, cancellation.token), isCancellationError);+		const remaining = prepareSession(repository, CancellationToken.None);+		cancellation.cancel();+		await cancelled;+		await reply.complete({ project: project({ status: 'cloning' }) });+		root.handleSnapshot(rootState([project()]), 1);+		assert.deepStrictEqual({+			directory: (await remaining)?.toString(),+			calls: requests.length,+		}, { directory: 'file:///workspaces/vscode', calls: 1 });+	});++	test('cancelling every waiter allows an immediate retry to start a fresh preparation', async () => {+		const { prepareSession, root, replies, requests } = createPreparation(store);+		const firstCancellation = store.add(new CancellationTokenSource());+		const secondCancellation = store.add(new CancellationTokenSource());+		const first = assert.rejects(prepareSession(repository, firstCancellation.token), isCancellationError);+		const second = assert.rejects(prepareSession(repository, secondCancellation.token), isCancellationError);+		firstCancellation.cancel();+		secondCancellation.cancel();+		const retry = prepareSession(repository, CancellationToken.None);+		await Promise.all([first, second]);+		await replies[1].complete({ project: project() });+		root.handleSnapshot(rootState([project()]), 1);+		assert.deepStrictEqual({+			calls: requests.length,+			directory: (await retry)?.toString(),+		}, { calls: 2, directory: 'file:///workspaces/vscode' });+	});++	test('a newer ready publication wins over a delayed cloning response', async () => {+		const { prepareSession, root, reply } = createPreparation(store);+		const preparation = prepareSession(repository, CancellationToken.None);+		root.handleSnapshot(rootState([project()]), 1);+		await reply.complete({ project: project({ status: 'cloning', git: false }) });+		assert.strictEqual((await preparation)?.toString(), 'file:///workspaces/vscode');+	});++	test('a ready response still requires catalogue read-back', async () => {+		const { prepareSession, root, reply } = createPreparation(store);+		let settled = false;+		const preparation = prepareSession(repository, CancellationToken.None).then(result => {+			settled = true;+			return result;+		});+		await reply.complete({ project: project() });+		await timeout(0);+		assert.strictEqual(settled, false);+		root.handleSnapshot(rootState([project()]), 1);+		assert.strictEqual((await preparation)?.toString(), 'file:///workspaces/vscode');+	});++	test('surfaces clone failure without falling back to the default directory', async () => {+		const { prepareSession, root, requests } = createPreparation(store, [project({ status: 'cloning' })]);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /access denied/);+		root.handleSnapshot(rootState([project({ status: 'failed', error: 'access denied' })]), 1);+		await rejected;+		assert.deepStrictEqual(requests, []);+	});++	test('rejects failed clone replies immediately without waiting for a catalogue publication', async () => {+		for (const error of [undefined, 'access denied']) {+			const { prepareSession, reply } = createPreparation(store);+			const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), {+				message: error ? `Repository cloning failed: ${error}` : 'Repository cloning failed.',+			});+			await reply.complete({ project: project({ status: 'failed', remoteUrl: undefined, error }) });+			await rejected;+		}+	});++	test('a failed shared clone reply reaches every waiter and allows an explicit retry', async () => {+		const { prepareSession, root, replies, requests } = createPreparation(store);+		const first = assert.rejects(prepareSession(repository, CancellationToken.None), /access denied/);+		const second = assert.rejects(prepareSession(repository, CancellationToken.None), /access denied/);+		root.handleSnapshot(rootState([project({ status: 'cloning' })]), 1);+		await replies[0].complete({ project: project({ status: 'failed', error: 'access denied' }) });+		await Promise.all([first, second]);+		root.handleSnapshot(rootState([]), 2);+		const retry = prepareSession(repository, CancellationToken.None);+		await replies[1].complete({ project: project() });+		root.handleSnapshot(rootState([project()]), 3);+		assert.deepStrictEqual({ calls: requests.length, directory: (await retry)?.toString() }, { calls: 2, directory: 'file:///workspaces/vscode' });+	});++	test('an explicit retry can restart a failed clone', async () => {+		const { prepareSession, root, reply, requests } = createPreparation(store, [project({ status: 'failed', error: 'previous failure' })]);+		const preparation = prepareSession(repository, CancellationToken.None);+		root.handleSnapshot(rootState([project({ status: 'cloning' })]), 1);+		await reply.complete({ project: project({ status: 'cloning' }) });+		root.handleSnapshot(rootState([project()]), 2);+		assert.deepStrictEqual({ directory: (await preparation)?.toString(), calls: requests.length }, { directory: 'file:///workspaces/vscode', calls: 1 });+	});++	test('waits for the retry publication when its response overtakes the catalogue', async () => {+		const { prepareSession, root, reply } = createPreparation(store, [project({ status: 'failed', error: 'previous failure' })]);+		const preparation = prepareSession(repository, CancellationToken.None);+		await reply.complete({ project: project({ status: 'cloning' }) });+		await timeout(0);+		root.handleSnapshot(rootState([project()]), 1);+		assert.strictEqual((await preparation)?.toString(), 'file:///workspaces/vscode');+	});++	test('a repeated clone failure is reported without an automatic retry', async () => {+		const failed = project({ status: 'failed', error: 'access denied' });+		const { prepareSession, root, reply, requests } = createPreparation(store, [failed]);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /access denied/);+		root.handleSnapshot(rootState([project({ status: 'cloning' })]), 1);+		root.handleSnapshot(rootState([failed]), 2);+		await reply.complete({ project: project({ status: 'cloning' }) });+		await rejected;+		assert.strictEqual(requests.length, 1);+	});++	test('fails if a joined project is removed', async () => {+		const { prepareSession, root } = createPreparation(store, [project({ status: 'cloning' })]);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /removed/);+		root.handleSnapshot(rootState([]), 1);+		await rejected;+	});++	test('detects removal even when it precedes the clone response', async () => {+		const { prepareSession, root, reply } = createPreparation(store);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /removed/);+		root.handleSnapshot(rootState([project({ status: 'cloning' })]), 1);+		root.handleSnapshot(rootState([]), 2);+		await reply.complete({ project: project({ status: 'cloning' }) });+		await rejected;+	});++	test('rejects malformed clone responses and a different repository', async () => {+		for (const response of [null, {}, { project: project({ id: '' }) }, { project: project({ remoteUrl: 'https://github.com/other/repo' }) }]) {+			const { prepareSession, reply } = createPreparation(store);+			const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /invalid repository cloning response/);+			await reply.complete(response);+			await rejected;+		}+	});++	test('requires a ready Git checkout with an absolute host path', async () => {+		for (const entry of [project({ git: false }), project({ path: 'relative/repo' }), project({ path: 'file:///repo' }), project({ path: '/repo\0' })]) {+			const { prepareSession } = createPreparation(store, [entry]);+			await assert.rejects(prepareSession(repository, CancellationToken.None), /usable repository directory/);+		}+	});++	test('rejects credential-bearing and non-repository URLs without sending them', async () => {+		const { prepareSession, requests } = createPreparation(store);+		for (const url of ['https://user:secret@github.com/microsoft/vscode', 'https://github.com/microsoft/vscode?token=secret', 'https://github.com/microsoft/vscode#main', 'https://example.org/microsoft/vscode']) {+			await assert.rejects(prepareSession(URI.parse(url), CancellationToken.None), /requires a GitHub repository URL/);+		}+		assert.deepStrictEqual(requests, []);+	});++	test('propagates RPC and subscription failures', async () => {+		const cloning = createPreparation(store);+		const rpcRejected = assert.rejects(cloning.prepareSession(repository, CancellationToken.None), /RPC unavailable/);+		await cloning.reply.error(new Error('RPC unavailable'));+		await rpcRejected;++		const waiting = createPreparation(store, [project({ status: 'cloning' })]);+		const subscriptionRejected = assert.rejects(waiting.prepareSession(repository, CancellationToken.None), /subscription failed/);+		waiting.root.setError(new Error('subscription failed'));+		await subscriptionRejected;+	});++	test('does not fall back when the capability is withdrawn during preparation', async () => {+		const { prepareSession, root } = createPreparation(store, [project({ status: 'cloning' })]);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /no longer advertises/);+		root.handleSnapshot(rootState([], { available: false }), 1);+		await rejected;+	});++	test('cancelling a wait leaves the checkout available for a later retry', async () => {+		const { prepareSession, root, reply, requests } = createPreparation(store);+		const cancellation = store.add(new CancellationTokenSource());+		const rejected = assert.rejects(prepareSession(repository, cancellation.token), isCancellationError);+		cancellation.cancel();+		await rejected;+		root.handleSnapshot(rootState([project()]), 1);+		await reply.complete({ project: project({ status: 'cloning' }) });+		assert.deepStrictEqual({ directory: (await prepareSession(repository, CancellationToken.None))?.toString(), calls: requests.length }, {+			directory: 'file:///workspaces/vscode', calls: 1,+		});+	});++	test('connection disposal cancels preparation without deleting the project', async () => {+		const { prepareSession, lifetime, requests } = createPreparation(store, [project({ status: 'cloning' })]);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), isCancellationError);+		lifetime.dispose();+		await rejected;+		assert.deepStrictEqual(requests, []);+	});++	test('connection disposal cancels every waiter without waiting for a clone reply', async () => {+		const { prepareSession, lifetime, requests } = createPreparation(store);+		const first = assert.rejects(prepareSession(repository, CancellationToken.None), isCancellationError);+		const second = assert.rejects(prepareSession(repository, CancellationToken.None), isCancellationError);+		lifetime.dispose();+		await Promise.all([first, second]);+		assert.deepStrictEqual(requests.map(request => request.method), ['extensions/cloneProject']);+	});++	test('an already-cancelled request cannot start cloning', async () => {+		const { prepareSession, requests } = createPreparation(store);+		await assert.rejects(prepareSession(repository, CancellationToken.Cancelled), isCancellationError);+		assert.deepStrictEqual(requests, []);+	});++	test('the five-minute deadline covers a missing reply even after a ready publication', async () => {+		const clock = sinon.useFakeTimers();+		const { prepareSession, root } = createPreparation(store);+		let settled = false;+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /five minutes/).then(() => settled = true);+		root.handleSnapshot(rootState([project()]), 1);+		await clock.tickAsync(5 * 60_000 - 1);+		assert.strictEqual(settled, false);+		await clock.tickAsync(1);+		await rejected;+	});++	test('the deadline also covers a clone that never becomes ready', async () => {+		const clock = sinon.useFakeTimers();+		const { prepareSession } = createPreparation(store, [project({ status: 'cloning' })]);+		const rejected = assert.rejects(prepareSession(repository, CancellationToken.None), /five minutes/);+		await clock.tickAsync(5 * 60_000);+		await rejected;+	});++	test('a shared timeout settles every waiter and allows a user retry', async () => {+		const clock = sinon.useFakeTimers();+		const { prepareSession, requests } = createPreparation(store);+		for (let attempt = 0; attempt < 3; attempt++) {+			const first = assert.rejects(prepareSession(repository, CancellationToken.None), /five minutes/);+			const second = assert.rejects(prepareSession(repository, CancellationToken.None), /five minutes/);+			await clock.tickAsync(5 * 60_000);+			await Promise.all([first, second]);+		}+		assert.strictEqual(requests.length, 3);+	});+});
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts18 + / 5
@@ -4,7 +4,7 @@  *--------------------------------------------------------------------------------------------*/  import { status } from '../../../../../../base/browser/ui/aria/aria.js';-import { Delayer, disposableTimeout, raceCancellation } from '../../../../../../base/common/async.js';+import { Delayer, disposableTimeout, raceCancellation, raceCancellationError } from '../../../../../../base/common/async.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { CancellationError, getErrorCode, isCancellationError } from '../../../../../../base/common/errors.js';@@ -887,6 +887,8 @@ export interface IAgentHostSessionHandlerConfig { 	 * falling back to the first workspace folder. 	 */ 	readonly resolveWorkingDirectory?: (sessionResource: URI) => URI | undefined;+	/** Prepares a new session's directory before resolving its customization scope and creating it. */+	readonly prepareSession?: (sessionResource: URI, token: CancellationToken) => Promise<void>; 	/** Whether a final-looking chat resource is still a client-side draft. */ 	readonly isNewSession?: (sessionResource: URI) => boolean; 	/** Called after a locally-created session has been accepted by the backend. */@@ -1913,6 +1915,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC 					Object.keys(initialConfig).length > 0 ? initialConfig : undefined, 					imported ? { turns: imported.turns, model: imported.model } : undefined, 					stage => failureStage = stage,+					cancellationToken, 				); 			} else { 				failureStage = 'authentication';@@ -5637,8 +5640,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC 	}  	/** Creates a new backend session and subscribes to its state. */-	private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, config?: Record<string, unknown>, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void): Promise<URI> {-		const workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource);+	private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, config?: Record<string, unknown>, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void, cancellationToken: CancellationToken = CancellationToken.None): Promise<URI> { 		const requestedSession = this._resolveSessionUri(sessionResource); 		const meta = this._provisionalService.getInitialSessionMetadata(sessionResource); @@ -5647,8 +5649,20 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC 		onFailureStage?.('authentication'); 		const protectedResources = await this._ensureRequiredAuthentication(model); +		onFailureStage?.('createSession');+		if (this._config.prepareSession) {+			const previousDirectory = this._resolveRequestedWorkingDirectory(sessionResource);+			await raceCancellationError(this._config.prepareSession(sessionResource, cancellationToken), cancellationToken);+			if (!isEqual(previousDirectory, this._resolveRequestedWorkingDirectory(sessionResource))) {+				this._disposeActiveClientEntry(sessionResource);+			}+		}+		const workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); 		const activeClientEntry = this._ensureActiveClientEntry(sessionResource);-		await activeClientEntry.whenSettled();+		await raceCancellationError(activeClientEntry.whenSettled(), cancellationToken);+		if (cancellationToken.isCancellationRequested) {+			throw new CancellationError();+		} 		const activeClient = this._getCurrentActiveClient(sessionResource);  		// Opt in to bring-up progress (chiefly the lazy first-use SDK download)@@ -5658,7 +5672,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC 		const progressToken = generateUuid();  		let session: URI;-		onFailureStage?.('createSession'); 		try { 			session = await this._config.connection.createSession({ 				session: requestedSession,
src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts138 + / 0
@@ -9,6 +9,7 @@ import { setARIAContainer } from '../../../../../../base/browser/ui/aria/aria.js import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js';+import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../base/common/network.js';@@ -13792,6 +13793,143 @@ suite('AgentHostChatContribution', () => { 			); 		}); +		test('prepares the working directory after authentication and rebinds its customization scope before creation', async () => {+			const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables);+			const requested = URI.parse('https://github.com/microsoft/vscode');+			const prepared = toAgentHostUri(URI.file('/workspaces/vscode'), 'remote-test');+			let directory = requested;+			const order: string[] = [];+			agentHostService.resourceUris = createAgentHostResourceUriMapper('remote-test');+			agentHostService.setInitializeResult({ defaultDirectory: 'file:///workspaces' });+			agentHostService.setRootState({+				agents: [{+					provider: 'copilot', displayName: 'Copilot', description: 'test', models: [],+					protectedResources: [{ resource: 'https://api.github.com', required: true }],+				}],+			});+			disposables.add(seedActiveClient('agent-host-copilot', {+				customizations: constObservable<readonly ClientPluginCustomization[]>([+					{ type: CustomizationType.Plugin, id: 'file:///repo-plugin', uri: 'file:///repo-plugin', name: 'Repository Plugin' },+				]),+			}, [prepared]));+			const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {+				provider: 'copilot',+				backendSessionScheme: 'ahp-session',+				agentId: 'agent-host-copilot',+				sessionType: 'agent-host-copilot',+				fullName: 'Agent Host - Copilot',+				description: 'test',+				connection: agentHostService,+				connectionAuthority: 'remote-test',+				resolveWorkingDirectory: () => directory,+				isNewSession: () => true,+				resolveAuthentication: async () => {+					order.push('authenticate');+					return true;+				},+				prepareSession: async () => {+					order.push('prepare');+					directory = prepared;+				},+			}));+			const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables);+			fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction);+			await turnPromise;+			assert.deepStrictEqual({+				order,+				created: agentHostService.createSessionCalls.map(call => ({+					session: call.session?.toString(),+					directories: call.workingDirectories?.map(directory => directory.toString()),+					customizations: call.activeClient?.customizations?.map(customization => customization.uri),+				})),+			}, {+				order: ['authenticate', 'prepare'],+				created: [{+					session: 'ahp-session:/new-turntest',+					directories: [prepared.toString()],+					customizations: ['file:///repo-plugin'],+				}],+			});+		});++		test('failed working-directory preparation prevents session creation and turns', async () => {+			const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);+			const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {+				provider: 'copilot',+				agentId: 'agent-host-copilot',+				sessionType: 'agent-host-copilot',+				fullName: 'Agent Host - Copilot',+				description: 'test',+				connection: agentHostService,+				connectionAuthority: 'local',+				prepareSession: async () => { throw new Error('Repository clone denied'); },+			}));+			const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-failed-preparation' });+			const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None);+			disposables.add(toDisposable(() => chatSession.dispose()));+			const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!;+			await assert.rejects(registered.impl.invoke(makeRequest({ sessionResource }), () => { }, [], CancellationToken.None), /Repository clone denied/);+			assert.deepStrictEqual({+				created: agentHostService.createSessionCalls.length,+				turns: agentHostService.turnActions.length,+			}, {+				created: 0,+				turns: 0,+			});+		});++		test('cancelling working-directory preparation prevents session creation', async () => {+			const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);+			const cancellation = disposables.add(new CancellationTokenSource());+			const pending = new DeferredPromise<void>();+			const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {+				provider: 'copilot',+				agentId: 'agent-host-copilot',+				sessionType: 'agent-host-copilot',+				fullName: 'Agent Host - Copilot',+				description: 'test',+				connection: agentHostService,+				connectionAuthority: 'local',+				prepareSession: () => pending.p,+			}));+			const { turnPromise } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { cancellationToken: cancellation.token });+			const rejected = assert.rejects(turnPromise, isCancellationError);+			cancellation.cancel();+			await rejected;+			await pending.complete();+			assert.deepStrictEqual({ created: agentHostService.createSessionCalls.length, turns: agentHostService.turnActions.length }, { created: 0, turns: 0 });+		});++		test('existing sessions do not prepare another working directory', async () => {+			const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);+			const backendSession = AgentSession.uri('copilot', 'existing-repository');+			agentHostService.sessionStates.set(backendSession.toString(), {+				...createSessionState({+					resource: backendSession.toString(), provider: 'copilot', title: 'Existing',+					status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(),+					workingDirectories: ['file:///existing'],+				}),+				lifecycle: SessionLifecycle.Ready,+			});+			let preparationCalls = 0;+			const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {+				provider: 'copilot',+				agentId: 'agent-host-copilot',+				sessionType: 'agent-host-copilot',+				fullName: 'Agent Host - Copilot',+				description: 'test',+				connection: agentHostService,+				connectionAuthority: 'local',+				prepareSession: async () => { preparationCalls++; },+			}));+			const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {+				sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/existing-repository' }),+			});+			fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction);+			await turnPromise;+			assert.deepStrictEqual({ preparationCalls, created: agentHostService.createSessionCalls.length }, { preparationCalls: 0, created: 0 });+		});+ 		test('waits for initial scope resolution before creating a session', async () => { 			const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); 			const customizations = observableValue<ClientPluginCustomization[]>('customizations', []);