diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index 1fb849eb68f1f..6c5c78f9fdeed 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -109,6 +109,12 @@ export const AHP_UNSUPPORTED_PROTOCOL_VERSION = -32005 as const; export const AHP_CONTENT_NOT_FOUND = -32006 as const; export const AHP_AUTH_REQUIRED = -32007 as const; +/** + * A named resource does not exist on the host. Not always a failure: a client may address a + * session it is about to create. + */ +export const AHP_NOT_FOUND = -32008 as const; + // ---- Type guards ----------------------------------------------------------- import type { AhpRequest, AhpNotification, AhpSuccessResponse, ProtocolMessage, JsonRpcErrorResponse } from './protocol/messages.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 2c08e35db0766..3c542196e0fe9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -45,8 +45,9 @@ import { IAgentSubscription, observableFromSubscription } from '../../../../../. import { ChatTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, ContentEncoding, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, SessionInputRequestKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type SessionInputRequest, type SessionToolClientExecutionRequest } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -122,6 +123,15 @@ const MAX_INLINED_UNSAVED_EDITOR_BYTES = 1024 * 1024; /** Stable id of the progress row mirroring the host's chat activity, so updates replace it in place. */ const CHAT_ACTIVITY_PROGRESS_ID = 'agentHost.chatActivity'; +/** + * First protocol version whose customizations carry list-shaped `enablement` rather than a plain + * `enabled` boolean. Hosts below it reject the newer shape outright. + * + * TODO@osortega: delete this and `_withVersionedCustomizations` once every reachable host speaks + * 0.8.0 — the cloud sandbox image was still on 0.7.0, and those hosts lose client customizations. + */ +const CUSTOMIZATION_ENABLEMENT_PROTOCOL_VERSION = '0.8.0'; + export const UNOBSERVED_CLIENT_TOOL_GRACE_MS = 5000; type AgentHostInvocationFailureStage = 'resolveSession' | 'provisionalSession' | 'sessionState' | 'authentication' | 'createSession' | 'subscribeSession' | 'prepareTurn' | 'dispatchTurn' | 'observeTurn'; @@ -317,6 +327,14 @@ function userOriginMessage(text: string, attachments: readonly MessageAttachment : { text, origin: { kind: MessageKind.User } }; } +/** + * Whether `err` reports that the host has no such resource (AHP `NotFound`), which a client can + * legitimately provoke by addressing a session `createSession` has yet to bring into being. + */ +export function isNotFoundError(err: unknown): boolean { + return err instanceof ProtocolError && err.code === AHP_NOT_FOUND; +} + /** * Extracts a user-facing message from a session-load failure so the actual cause * (e.g. a git worktree-recreation error) is shown instead of a generic message. @@ -1484,7 +1502,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // underlying error message (e.g. the git worktree-recreation // failure) so the user sees the actual cause, falling back to a // generic message. - if (history.length === 0) { + // Excluded: an id the host learns at `createSession` is not a load failure. + if (history.length === 0 && !isNotFoundError(err)) { history.push({ type: 'request', prompt: '', @@ -2119,7 +2138,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _getCurrentActiveClient(sessionResource: URI): SessionActiveClient { const entry = this._activeClientEntries.get(sessionResource); if (entry) { - return entry.getActiveClient(); + return this._withVersionedCustomizations(entry.getActiveClient()); } return { @@ -2129,6 +2148,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; } + /** + * Drop client-published customizations for a host older than list-shaped enablement, which + * rejects the newer shape outright with `invalid params: missing field 'enabled'`. Temporary; + * see {@link CUSTOMIZATION_ENABLEMENT_PROTOCOL_VERSION}. + */ + private _withVersionedCustomizations(activeClient: SessionActiveClient): SessionActiveClient { + const hostVersion = this._config.connection.initializeResult.get()?.protocolVersion; + if (!activeClient.customizations?.length + || !hostVersion + || compareProtocolVersions(hostVersion, CUSTOMIZATION_ENABLEMENT_PROTOCOL_VERSION) >= 0) { + return activeClient; + } + this._logService.warn(`[AgentHost] Host speaks protocol ${hostVersion}; dropping ${activeClient.customizations.length} client customization(s) that require ${CUSTOMIZATION_ENABLEMENT_PROTOCOL_VERSION}.`); + return { ...activeClient, customizations: [] }; + } + private _ensureActiveClient(sessionResource: URI, backendSession: URI): ActiveClientEntry | undefined { const entry = this._ensureActiveClientEntry(sessionResource); if (!entry) { @@ -2154,7 +2189,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.clientId, AgentHostSessionHandler.ACTIVE_CLIENT_RECONCILIATION_DEBOUNCE_MS, backendSession => this._getSessionState(backendSession.toString()), - (backendSession, action) => this._dispatchAction(backendSession, action), + // Reconciliation republishes customizations, so it needs the same gate. + (backendSession, action) => this._dispatchAction(backendSession, action.type === ActionType.SessionActiveClientSet + ? { ...action, activeClient: this._withVersionedCustomizations(action.activeClient) } + : action), ); this._activeClientEntries.set(sessionResource, entry); return entry; @@ -5534,7 +5572,27 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** `undefined` is preserved for createSession to let the host choose its working directories. */ private _resolveRequestedWorkingDirectories(sessionResource: URI): readonly URI[] | undefined { const primary = this._resolveRequestedWorkingDirectory(sessionResource); - return computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._getRootState(), this._config.provider); + return this._hostAddressableWorkingDirectories( + computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._getRootState(), this._config.provider) + ); + } + + /** + * Drop working directories whose scheme the host cannot address, falling back to `undefined` so + * it picks its own. Schemes are compared as the host receives them, after unwrapping. + */ + private _hostAddressableWorkingDirectories(directories: readonly URI[] | undefined): readonly URI[] | undefined { + const defaultDirectory = this._config.connection.initializeResult.get()?.defaultDirectory; + if (!directories?.length || !defaultDirectory) { + return directories; + } + const hostScheme = URI.isUri(defaultDirectory) ? URI.revive(defaultDirectory).scheme : URI.parse(defaultDirectory).scheme; + const addressable = directories.filter(directory => this._config.connection.resourceUris.toAgentHost(directory).scheme === hostScheme); + if (addressable.length === directories.length) { + return directories; + } + this._logService.warn(`[AgentHost] Host addresses '${hostScheme}' working directories; dropping ${directories.length - addressable.length} that it cannot use.`); + return addressable.length > 0 ? addressable : undefined; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 3a6cd51dff9b2..f96258b8294a3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -26,17 +26,17 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; -import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { ActionType, AuthRequiredReason, isSessionAction, isChatAction, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AHP_NOT_FOUND, ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ChatInteractivity, ConfirmationOptionKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type AgentCustomization, type ClientPluginCustomization, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult, type InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; @@ -164,7 +164,17 @@ class MockAgentHostService extends mock() { this._authenticationPending.set(pending, undefined); } - override readonly initializeResult = constObservable(undefined); + private readonly _initializeResult = observableValue('initializeResult', undefined); + override readonly initializeResult: IObservable = this._initializeResult; + + /** Declares what the host reported at `initialize`, for version- and scheme-gated behaviour. */ + setInitializeResult(result: Partial): void { + this._initializeResult.set({ ...this._initializeResult.get(), ...result } as InitializeResult, undefined); + } + + setHostProtocolVersion(protocolVersion: string): void { + this.setInitializeResult({ protocolVersion }); + } // Track live subscriptions so fireAction can route to them. A subscription // may hold a SessionState (for session channels) or a ChatState (for the @@ -177,6 +187,9 @@ class MockAgentHostService extends mock() { public createSessionCalls: IAgentCreateSessionConfig[] = []; public disposedSessions: URI[] = []; public failNextSubscriptionFor = new Set(); + + /** Error to fail a subscription with, when the default generic error is not what is under test. */ + public failNextSubscriptionError = new Map(); public agents = [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', requiresAuth: true }]; // ---- Pending→error subscription support (repro for #5242) -------------- @@ -358,7 +371,7 @@ class MockAgentHostService extends mock() { const onDidApply = new Emitter(); if (this.failNextSubscriptionFor.delete(resourceStr)) { - const error = new Error(`Session not found on backend: ${resourceStr}`); + const error = this.failNextSubscriptionError.get(resourceStr) ?? new Error(`Session not found on backend: ${resourceStr}`); return { object: { get value() { return error; }, @@ -11635,6 +11648,207 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(createCall!.activeClient!.customizations?.[0].uri, 'file:///plugin-a'); }); + test('drops customizations for a host older than list-shaped enablement', async () => { + // A 0.7.0 host rejects the whole createSession with `missing field 'enabled'`. + const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); + agentHostService.setHostProtocolVersion('0.7.0'); + + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-a', uri: 'file:///plugin-a', name: 'Plugin A' }, + ]); + disposables.add(seedActiveClient('agent-host-copilot', { customizations })); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + 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; + + const createCall = agentHostService.createSessionCalls.at(-1); + assert.deepStrictEqual(createCall?.activeClient?.customizations, []); + }); + + test('drops customizations from a reconciliation republish for an older host', async () => { + // Gating only createSession leaves this path sending the shape 0.7.0 rejects. + const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); + agentHostService.setHostProtocolVersion('0.7.0'); + + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-a', uri: 'file:///plugin-a', name: 'Plugin A' }, + ]); + disposables.add(seedActiveClient('agent-host-copilot', { customizations })); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + 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; + agentHostService.dispatchedActions.length = 0; + + customizations.set([ + { type: CustomizationType.Plugin, id: 'file:///plugin-b', uri: 'file:///plugin-b', name: 'Plugin B' }, + ], undefined); + await timeout(10); + + assert.deepStrictEqual( + agentHostService.dispatchedActions + .filter(action => action.action.type === ActionType.SessionActiveClientSet) + .map(action => (action.action as { activeClient: SessionActiveClient }).activeClient.customizations), + [[]], + ); + }); + + test('keeps customizations for a host that speaks list-shaped enablement', async () => { + const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); + agentHostService.setHostProtocolVersion('0.8.0'); + + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-a', uri: 'file:///plugin-a', name: 'Plugin A' }, + ]); + disposables.add(seedActiveClient('agent-host-copilot', { customizations })); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + 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; + + const createCall = agentHostService.createSessionCalls.at(-1); + assert.strictEqual(createCall?.activeClient?.customizations?.length, 1); + }); + + test('does not render a load failure for a session the host has not created yet', async () => { + // A seeded id reports NotFound until `createSession` brings the session into being. + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/seeded-not-yet-created' }); + const backendSession = AgentSession.uri('copilot', 'seeded-not-yet-created'); + const { sessionHandler, agentHostService } = createContribution(disposables, { + provisionalServiceOverride: { + get: resource => resource.toString() === sessionResource.toString() ? backendSession : undefined, + }, + }); + agentHostService.failNextSubscriptionFor.add(backendSession.toString()); + agentHostService.failNextSubscriptionError.set(backendSession.toString(), new ProtocolError(AHP_NOT_FOUND, `not found: ${backendSession.toString()}`)); + + const content = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + + assert.deepStrictEqual(content.history, []); + }); + + test('still renders a load failure when the session exists but cannot be opened', async () => { + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/existing-broken' }); + const backendSession = AgentSession.uri('copilot', 'existing-broken'); + const { sessionHandler, agentHostService } = createContribution(disposables, { + provisionalServiceOverride: { + get: resource => resource.toString() === sessionResource.toString() ? backendSession : undefined, + }, + }); + agentHostService.failNextSubscriptionFor.add(backendSession.toString()); + agentHostService.failNextSubscriptionError.set(backendSession.toString(), new Error('worktree could not be recreated')); + + const content = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + + assert.deepStrictEqual(content.history.map(item => item.type), ['request', 'response']); + }); + + test('drops a working directory the host cannot address, letting it choose its own', async () => { + // The workspace is the remote repo, but the agent runs against the local checkout. + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, + { resolve: () => URI.parse('https://github.com/microsoft/vscode') }); + agentHostService.setInitializeResult({ defaultDirectory: 'file:///workspaces/vscode' }); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + 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.strictEqual(agentHostService.createSessionCalls.at(-1)?.workingDirectories, undefined); + }); + + test('keeps a remote working directory the host can address once unwrapped', async () => { + // createSession unwraps `vscode-agent-host:`, so judging the wrapper drops valid dirs. + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, + { resolve: () => toAgentHostUri(URI.file('/workspaces/vscode'), 'remote-test') }); + agentHostService.resourceUris = createAgentHostResourceUriMapper('remote-test'); + agentHostService.setInitializeResult({ defaultDirectory: 'file:///workspaces/other' }); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'remote-test', + })); + + 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( + agentHostService.createSessionCalls.at(-1)?.workingDirectories?.map(d => URI.revive(d).toString()), + [toAgentHostUri(URI.file('/workspaces/vscode'), 'remote-test').toString()], + ); + }); + + test('keeps a working directory whose scheme the host addresses', async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, + { resolve: () => URI.file('/workspaces/vscode') }); + agentHostService.setInitializeResult({ defaultDirectory: 'file:///workspaces/other' }); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + 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( + agentHostService.createSessionCalls.at(-1)?.workingDirectories?.map(d => d.toString()), + [URI.file('/workspaces/vscode').toString()], + ); + }); + test('waits for initial scope resolution before creating a session', async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); const customizations = observableValue('customizations', []);