From 6b14e705e171fa29e07dfe6985e247c074eba6d4 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 17:03:23 -0700 Subject: [PATCH 1/4] Let agent host sessions tolerate hosts that differ from the client Three ways a session against a host that is older than, or shaped differently from, the client would fail outright. Customizations changed from an `enabled` boolean to list-shaped `enablement` in protocol 0.8.0. A host below that version expects the superseded shape and rejects the whole request with `invalid params: missing field 'enabled'`, which fails `createSession` and leaves no session at all. Client-published customizations are now dropped for such a host, degrading to a session without them. The gate is applied on both paths that publish an active client, since reconciliation republishes it after creation. A host can also address only certain working directory schemes. A remote workspace may be the repository itself (`https://github.com/owner/repo`) while the agent runs against a checkout on its own disk, and sending the remote URI fails `createSession` with `unsupported scheme 'https'; expected 'file'`. Working directories the host cannot address are now dropped, falling back to letting it choose its own. The host's reported `defaultDirectory` names the scheme it can address, so this adapts per host rather than hardcoding one. Finally, a session the host has never heard of rendered "Couldn't open session". A provider can legitimately address a session id before the host knows it, with `createSession` bringing it into being, so the banner is now suppressed for `NotFound` specifically. Genuine open failures still render, which a test covers directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionProtocol.ts | 8 + .../agentHost/agentHostSessionHandler.ts | 78 ++++++++- .../agentHostChatContribution.test.ts | 163 +++++++++++++++++- 3 files changed, 240 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index 1fb849eb68f1fe..abd52bd42aa684 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -109,6 +109,14 @@ 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 — a session, chat, or other channel URI. + * + * Not always a failure: a client may address a session it is about to create, as the cloud sandbox + * does with the id Mission Control minted before the host knew of it. + */ +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 2c08e35db07669..f6322006a505d5 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,12 @@ 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. + */ +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 +324,16 @@ function userOriginMessage(text: string, attachments: readonly MessageAttachment : { text, origin: { kind: MessageKind.User } }; } +/** + * Whether `err` reports that the host has no such resource (AHP `NotFound`). + * + * A client can legitimately address a session before the host knows it: the cloud sandbox seeds + * the id Mission Control minted, and `createSession` is what brings it 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 +1501,10 @@ 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) { + // A session the host has never heard of is excluded: a provider can seed an id + // the host only learns at `createSession`, so the first turn brings it into + // being and "Couldn't open session" would report a failure that step resolves. + if (history.length === 0 && !isNotFoundError(err)) { history.push({ type: 'request', prompt: '', @@ -2119,7 +2139,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 +2149,25 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; } + /** + * Drop client-published customizations when the host is older than the protocol version that + * introduced list-shaped enablement. + * + * Such a host expects the superseded `enabled` boolean and rejects the whole request with + * `invalid params: missing field 'enabled'`, failing `createSession`. Sending nothing degrades + * to a session without client customizations, which beats no session at all. + */ + 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 +2193,11 @@ 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 the active client, so it carries customizations too and + // needs the same version gate `createSession` applies. + (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 +5577,32 @@ 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 the host cannot address, falling back to `undefined` so it picks + * its own. + * + * A cloud sandbox workspace is the remote repository (`https://github.com/owner/repo`), but the + * agent runs against a checkout on the sandbox's disk, so sending the remote URI fails + * `createSession` with `unsupported scheme 'https'; expected 'file'`. The host's own default + * names the scheme it can address, so this adapts rather than hardcoding one. + */ + 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 => 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 3a6cd51dff9b29..60d376bb0004cf 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 @@ -33,10 +33,10 @@ import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../. 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,148 @@ 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 plugin carries `enablement`/`childEnablement` from 0.8.0 on. A 0.7.0 host still + // expects the superseded `enabled` boolean and rejects the whole `createSession` with + // `invalid params: missing field 'enabled'`, leaving the session uncreated — so send + // none rather than lose the session. + 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('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 () => { + // The cloud sandbox seeds the id Mission Control minted, so the first subscribe + // legitimately reports NotFound and `createSession` brings the session into being. + // Reporting "Couldn't open session" there names a failure the next step resolves. + 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 () => { + // A cloud sandbox workspace is the remote repository, but the agent runs against the + // sandbox's own checkout. Sending the remote URI fails createSession outright. + 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 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', []); From 7c39b1893f385f4f96304cee5079fc4ee11a0eff Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 19:38:12 -0700 Subject: [PATCH 2/4] Judge working directories by the scheme the host receives Remote folders travel as `vscode-agent-host:` wrappers on the client and are unwrapped by `createSession` before being sent, so comparing the wrapper scheme against the host's `defaultDirectory` dropped SSH and tunnel working directories the host addresses perfectly well. Compare the unwrapped scheme instead, which is what the host actually receives. Also covers the reconciliation republish path, which had no test even though an ungated republish is enough for an older host to reject the update. Both tests were checked against the bugs they describe by reverting each fix and confirming the matching test fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionHandler.ts | 6 +- .../agentHostChatContribution.test.ts | 70 ++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) 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 f6322006a505d5..4a163321723935 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -5590,6 +5590,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * agent runs against a checkout on the sandbox's disk, so sending the remote URI fails * `createSession` with `unsupported scheme 'https'; expected 'file'`. The host's own default * names the scheme it can address, so this adapts rather than hardcoding one. + * + * Schemes are compared as the host will receive them: a remote folder travels as a + * `vscode-agent-host:` wrapper that `createSession` unwraps, so judging the wrapper would drop + * directories the host addresses perfectly well. */ private _hostAddressableWorkingDirectories(directories: readonly URI[] | undefined): readonly URI[] | undefined { const defaultDirectory = this._config.connection.initializeResult.get()?.defaultDirectory; @@ -5597,7 +5601,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return directories; } const hostScheme = URI.isUri(defaultDirectory) ? URI.revive(defaultDirectory).scheme : URI.parse(defaultDirectory).scheme; - const addressable = directories.filter(directory => directory.scheme === hostScheme); + const addressable = directories.filter(directory => this._config.connection.resourceUris.toAgentHost(directory).scheme === hostScheme); if (addressable.length === directories.length) { return directories; } 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 60d376bb0004cf..8476cd5d080a10 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,7 +26,7 @@ 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'; @@ -11679,6 +11679,45 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(createCall?.activeClient?.customizations, []); }); + test('drops customizations from a reconciliation republish for an older host', async () => { + // Reconciliation republishes the active client after creation, so gating only + // createSession leaves this path sending the shape a 0.7.0 host 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'); @@ -11765,6 +11804,35 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.createSessionCalls.at(-1)?.workingDirectories, undefined); }); + test('keeps a remote working directory the host can address once unwrapped', async () => { + // A remote (SSH/tunnel) folder is wrapped as `vscode-agent-host:` on the client, but + // createSession unwraps it before sending. Judging the wrapper scheme would drop a + // directory the host addresses perfectly well. + 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') }); From 6dfb2554f018b94e09b518f7610a3ad3fbb89b67 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 20:01:24 -0700 Subject: [PATCH 3/4] Trim comments to the repository's length limits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionProtocol.ts | 6 ++-- .../agentHost/agentHostSessionHandler.ts | 34 +++++-------------- .../agentHostChatContribution.test.ts | 19 +++-------- 3 files changed, 15 insertions(+), 44 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index abd52bd42aa684..6c5c78f9fdeed4 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -110,10 +110,8 @@ 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 — a session, chat, or other channel URI. - * - * Not always a failure: a client may address a session it is about to create, as the cloud sandbox - * does with the id Mission Control minted before the host knew of it. + * 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; 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 4a163321723935..df8588b86ec167 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -325,10 +325,8 @@ function userOriginMessage(text: string, attachments: readonly MessageAttachment } /** - * Whether `err` reports that the host has no such resource (AHP `NotFound`). - * - * A client can legitimately address a session before the host knows it: the cloud sandbox seeds - * the id Mission Control minted, and `createSession` is what brings it into being. + * 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; @@ -1501,9 +1499,7 @@ 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. - // A session the host has never heard of is excluded: a provider can seed an id - // the host only learns at `createSession`, so the first turn brings it into - // being and "Couldn't open session" would report a failure that step resolves. + // Excluded: an id the host learns at `createSession` is not a load failure. if (history.length === 0 && !isNotFoundError(err)) { history.push({ type: 'request', @@ -2150,12 +2146,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** - * Drop client-published customizations when the host is older than the protocol version that - * introduced list-shaped enablement. - * - * Such a host expects the superseded `enabled` boolean and rejects the whole request with - * `invalid params: missing field 'enabled'`, failing `createSession`. Sending nothing degrades - * to a session without client customizations, which beats no session at all. + * Drop client-published customizations for a host older than list-shaped enablement, which + * rejects the newer shape outright with `invalid params: missing field 'enabled'`. */ private _withVersionedCustomizations(activeClient: SessionActiveClient): SessionActiveClient { const hostVersion = this._config.connection.initializeResult.get()?.protocolVersion; @@ -2193,8 +2185,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.clientId, AgentHostSessionHandler.ACTIVE_CLIENT_RECONCILIATION_DEBOUNCE_MS, backendSession => this._getSessionState(backendSession.toString()), - // Reconciliation republishes the active client, so it carries customizations too and - // needs the same version gate `createSession` applies. + // 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), @@ -5583,17 +5574,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** - * Drop working directories the host cannot address, falling back to `undefined` so it picks - * its own. - * - * A cloud sandbox workspace is the remote repository (`https://github.com/owner/repo`), but the - * agent runs against a checkout on the sandbox's disk, so sending the remote URI fails - * `createSession` with `unsupported scheme 'https'; expected 'file'`. The host's own default - * names the scheme it can address, so this adapts rather than hardcoding one. - * - * Schemes are compared as the host will receive them: a remote folder travels as a - * `vscode-agent-host:` wrapper that `createSession` unwraps, so judging the wrapper would drop - * directories the host addresses perfectly well. + * 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; 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 8476cd5d080a10..f96258b8294a35 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 @@ -11649,10 +11649,7 @@ suite('AgentHostChatContribution', () => { }); test('drops customizations for a host older than list-shaped enablement', async () => { - // A plugin carries `enablement`/`childEnablement` from 0.8.0 on. A 0.7.0 host still - // expects the superseded `enabled` boolean and rejects the whole `createSession` with - // `invalid params: missing field 'enabled'`, leaving the session uncreated — so send - // none rather than lose the session. + // 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'); @@ -11680,8 +11677,7 @@ suite('AgentHostChatContribution', () => { }); test('drops customizations from a reconciliation republish for an older host', async () => { - // Reconciliation republishes the active client after creation, so gating only - // createSession leaves this path sending the shape a 0.7.0 host rejects. + // 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'); @@ -11746,9 +11742,7 @@ suite('AgentHostChatContribution', () => { }); test('does not render a load failure for a session the host has not created yet', async () => { - // The cloud sandbox seeds the id Mission Control minted, so the first subscribe - // legitimately reports NotFound and `createSession` brings the session into being. - // Reporting "Couldn't open session" there names a failure the next step resolves. + // 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, { @@ -11781,8 +11775,7 @@ suite('AgentHostChatContribution', () => { }); test('drops a working directory the host cannot address, letting it choose its own', async () => { - // A cloud sandbox workspace is the remote repository, but the agent runs against the - // sandbox's own checkout. Sending the remote URI fails createSession outright. + // 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' }); @@ -11805,9 +11798,7 @@ suite('AgentHostChatContribution', () => { }); test('keeps a remote working directory the host can address once unwrapped', async () => { - // A remote (SSH/tunnel) folder is wrapped as `vscode-agent-host:` on the client, but - // createSession unwraps it before sending. Judging the wrapper scheme would drop a - // directory the host addresses perfectly well. + // 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'); From 1695c6b9066579ceeffbe8d4558450920d5ce3d3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 20:42:09 -0700 Subject: [PATCH 4/4] Mark the customization version gate as temporary The gate only exists because reachable hosts still speak 0.7.0, and a host on that version silently loses its client customizations. Nothing in the code said when it should go, so it would outlive the reason for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentHost/agentHostSessionHandler.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 df8588b86ec167..3c542196e0fe93 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -126,6 +126,9 @@ 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'; @@ -2147,7 +2150,8 @@ 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'`. + * 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;