microsoft/vscode · #337173
sessions: Prepare cloud sandbox repositories before session creation
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', []);