From 0bd7148c0f3cfd0bcdf40fde30046698370fb4e1 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 15 Sep 2026 18:34:52 -0700 Subject: [PATCH 1/4] agentHost: Use schema-driven repository session creation Consume the optional repository descriptor proposed in microsoft/agent-host-protocol#451. Forward selected repository intent through session configuration, leaving checkout preparation to the host and retaining legacy directory behavior when the descriptor is absent. Wait for repository readiness and resolved directories before sending a turn, rebind customization scopes, preserve local workspace trust, and verify repository intent when recovering an existing session. Render preparation progress without assuming download units. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-root/commands.ts | 6 +- .../protocol/channels-root/notifications.ts | 3 + .../protocol/channels-session/commands.ts | 16 ++ .../state/protocol/channels-session/state.ts | 37 +++- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 8 + .../agentHost/agentHostDownloadProgress.ts | 24 +- .../agentHost/agentHostRepositoryConfig.ts | 138 ++++++++++++ .../agentHost/agentHostSessionHandler.ts | 66 ++++-- .../agentHostChatContribution.test.ts | 168 +++++++++++++- .../agentHostDownloadProgress.test.ts | 20 +- .../agentHostRepositoryConfig.test.ts | 206 ++++++++++++++++++ 12 files changed, 652 insertions(+), 42 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 7dc5824dd8a371..73773076e1a461 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -fd0471d4 +5eadbe33 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts index 019f367dad0c09..d13348122325b2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts @@ -13,7 +13,7 @@ import type { SessionSummary, SessionConfigSchema } from '../channels-session/st // Re-export schema types so the legacy `commands.ts` aggregator continues to // expose them from the same import path. export type { ConfigPropertySchema, ConfigSchema } from '../common/state.js'; -export type { SessionConfigPropertySchema, SessionConfigSchema } from '../channels-session/state.js'; +export type { RepositorySessionConfig, SessionConfigPropertySchema, SessionConfigSchema } from '../channels-session/state.js'; // ─── listSessions ──────────────────────────────────────────────────────────── @@ -79,6 +79,10 @@ export interface ListSessionsResult extends PaginatedResult { * the full current property set (not a delta). The returned `values` contain * server-resolved defaults to pass to `createSession`. * + * Repository-backed creation is advertised by `schema.repository`. Resolving + * that schema or its values MUST NOT clone or prepare a repository; preparation + * belongs to `createSession`. + * * @category Commands * @method resolveSessionConfig * @direction Client → Server diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 4032839fbc0690..6a24571e13f0b1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -176,6 +176,9 @@ export interface SessionSummaryChangedParams { * - Like all notifications this is ephemeral and is **not** replayed on * reconnect. A client that never receives the terminal frame SHOULD expire * the indicator after an idle timeout. + * - Completion of reported work does not establish session readiness. + * Repository-backed creation uses session state and the existing + * `session/ready` or `session/creationFailed` actions for its durable outcome. * * @category Protocol Notifications * @method root/progress diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 3492da93cad484..d38b5e089850a6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -23,6 +23,13 @@ import type { MessageAttachment } from '../channels-chat/state.js'; * updates. The server also broadcasts a `root/sessionAdded` notification to all * clients. * + * For repository intent advertised by {@link RepositorySessionConfig}, the + * host MUST authorize the request before repository side effects and prepare + * the repository before executing turns. It MUST publish the requested intent + * in {@link SessionState.config} and any resolved `workingDirectories` before + * `session/ready` or `session/creationFailed`. Clients recover the outcome from + * session state, not progress notifications. + * * @category Commands * @method createSession * @direction Client → Server @@ -64,11 +71,17 @@ export interface CreateSessionParams extends BaseParams { * and ignores the rest. Dispatch working-directory actions to change the set * after the session has started. * + * A non-empty list and repository intent in `config` are mutually exclusive. + * A repository URI is not a working-directory URI. */ workingDirectories?: URI[]; /** * Agent-specific configuration values collected via `resolveSessionConfig`. * Keys and values correspond to the schema returned by the server. + * Repository intent uses only the properties identified by the advertised + * {@link SessionConfigSchema.repository} descriptor. A revision without a + * repository URI is invalid. Omitting repository intent preserves existing + * directory/default behavior. */ config?: Record; /** @@ -101,6 +114,9 @@ export interface CreateSessionParams extends BaseParams { * Disposes a session and cleans up server-side resources. * * The server broadcasts a `root/sessionRemoved` notification to all clients. + * Disposal MUST NOT erase a shared checkout or uncommitted user changes. + * Repository cleanup remains host-owned; ending a client's wait or subscription + * does not grant permission to delete repository data. * * @category Commands * @method disposeSession diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 445f33494ce5b3..bdcd4a7295741f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -174,7 +174,11 @@ export interface SessionState extends SessionMetadata { * this over the session's lifetime. */ defaultChat?: URI; - /** Session configuration schema and current values */ + /** + * Session configuration schema and current values. For repository-backed + * creation, this includes the advertised repository descriptor and requested + * intent, so joining and reconnecting clients can recover it from state. + */ config?: SessionConfigState; /** * Top-level customizations active in this session. @@ -555,6 +559,31 @@ export interface SessionConfigPropertySchema extends ConfigPropertySchema { sessionMutable?: boolean; } +/** + * Opt-in descriptor for preparing one repository during session creation. + * + * Property ids are host-chosen and MUST name distinct entries in + * {@link SessionConfigSchema.properties}. Each referenced property MUST have + * `type: 'string'` and MUST NOT have `readOnly: true` or `sessionMutable: true`. + * Clients MUST use these ids rather than hardcoding repository field names. + * + * Values travel through `resolveSessionConfig.config` and `createSession.config`, + * not a separate command or `_meta`. Schema discovery MUST NOT clone or prepare + * a repository. The host accepts repository intent only when this descriptor + * is advertised. + * + * @category Session Config Types + */ +export interface RepositorySessionConfig { + /** Property id for a credential-free repository URI. */ + urlProperty: string; + /** + * Property id for an optional branch, tag, or commit revision. + * A revision value without a repository URI is invalid. + */ + revisionProperty?: string; +} + /** * A JSON Schema object describing available session configuration metadata. * @@ -567,6 +596,12 @@ export interface SessionConfigSchema { properties: Record; /** JSON Schema: list of required property ids */ required?: string[]; + /** + * Opt-in capability for repository-backed creation using existing config + * properties. The descriptor does not itself require a repository value. + * Without repository intent, existing directory/default behavior is unchanged. + */ + repository?: RepositorySessionConfig; } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 453ccc1b46115c..3b5179b905a2e0 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -61,6 +61,14 @@ Drafts expose the shared untitled `ISession` contract and use remote workspace m Remote session and chat resources preserve connection-specific routing identity through creation, hydration, and replacement. Backend session identifiers are translated only inside the provider. +### Repository-backed session creation + +A repository selection is intent, not a host filesystem directory. When the host cannot address the selected repository URI as a directory, the client discovers its session configuration. The optional standard `SessionConfigSchema.repository` descriptor identifies the declared URL and revision properties; the client passes those values through ordinary session creation without invoking a vendor cloning method. Hosts that do not advertise the descriptor retain the existing directory-selection behavior. + +The host owns checkout preparation and publishes its outcome through session state. A repository-backed session must reach `ready` with its selected repository and resolved directories before the client sends a turn. The client rebinds workspace-scoped customizations to those directories, propagates creation failures and allows a cancelled local wait to stop without disposing shared host resources. Reconnection observes the existing session; a lost creation reply must not cause an unrelated session to be accepted under the same URI. + +This is an optional protocol capability, not a requirement that every host use Git or materialize a local directory. Directory-based requests, existing sessions and hosts without repository configuration keep their existing behavior. + ## 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. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.ts index ce41db3954840b..de9f386a872720 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.ts @@ -23,22 +23,7 @@ interface IActiveDownload { complete(): void; } -/** - * Renders agent-host `progress` notifications as notification progress bars. - * - * Shared by the Agents window (via `BaseAgentHostSessionsProvider`) and the - * editor window (via `AgentHostContribution`) so both surfaces render the - * agent host's lazy, first-use SDK download identically. - * - * Progress is correlated by {@link ProgressParams.progressToken}; today's only - * producer is the SDK download, which the host surfaces as a single stream per - * provider keyed by the download's own stable identity — so one indicator per - * download regardless of how many sessions await it. Determinate when the host - * knows the `total` (`Content-Length`), or a byte-count spinner otherwise. The - * operation is complete — and the notification dismissed — once - * `progress >= total`. The human-readable brand noun rides on - * {@link ProgressParams.message}. - */ +/** Renders AHP progress for SDK downloads and other session preparation without assuming progress units. */ export class AgentHostDownloadProgress extends Disposable { /** @@ -90,7 +75,7 @@ export class AgentHostDownloadProgress extends Disposable { // a generic indicator that makes no assumption about what's downloading. const deferred = new DeferredPromise(); let report: ((step: IProgressStep) => void) | undefined; - const title = progress.message ?? localize('agentHost.download.titleFallback', "Downloading"); + const title = progress.message ?? localize('agentHost.progress.titleFallback', "Preparing Session"); this._progressService.withProgress( { location: ProgressLocation.Notification, @@ -119,10 +104,7 @@ export class AgentHostDownloadProgress extends Disposable { total: 100, }); } else { - // No total: indeterminate. Show megabytes received so the user - // still sees the download making progress. - const megabytes = (progress.progress / (1024 * 1024)).toFixed(1); - entry.report({ message: localize('agentHost.download.megabytes', "{0} MB", megabytes) }); + entry.report({ message: progress.message ?? localize('agentHost.progress.indeterminate', "Working...") }); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts new file mode 100644 index 00000000000000..50075863c109a7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceCancellationError } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { JsonRpcErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; +import { ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { RepositorySessionConfig, SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; +import { SessionConfigState, SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; + +/** Read the standard repository intent descriptor without guessing configuration property names. */ +export function readRepositorySessionConfig(schema: SessionConfigSchema | undefined): RepositorySessionConfig | undefined { + const descriptor = schema?.repository; + if (descriptor === undefined) { + return undefined; + } + const properties = schema?.properties; + const isInput = (property: string) => properties && Object.hasOwn(properties, property) + && properties[property]?.type === 'string' && properties[property].readOnly !== true && properties[property].sessionMutable !== true; + if (!descriptor || typeof descriptor !== 'object' + || typeof descriptor.urlProperty !== 'string' || !descriptor.urlProperty || !isInput(descriptor.urlProperty) + || (descriptor.revisionProperty !== undefined && (typeof descriptor.revisionProperty !== 'string' + || !descriptor.revisionProperty || descriptor.revisionProperty === descriptor.urlProperty || !isInput(descriptor.revisionProperty)))) { + throw new Error(localize('agentHost.invalidRepositoryConfig', "The agent host advertised an invalid repository configuration.")); + } + return descriptor; +} + +/** Read repository intent published in session state using the host's descriptor. */ +export function getRepositorySessionSource(config: SessionConfigState | undefined): string | undefined { + const descriptor = readRepositorySessionConfig(config?.schema); + const value = descriptor && config?.values[descriptor.urlProperty]; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || !value) { + throw new Error(localize('agentHost.invalidRepositoryValue', "The agent host returned an invalid repository selection.")); + } + return value; +} + +/** Resolve a selected repository through advertised session configuration; absence retains the legacy path. */ +export async function resolveAgentHostRepositoryConfig(connection: IAgentConnection, provider: string, repository: URI, config: Record | undefined, token: CancellationToken): Promise | undefined> { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + if (!repository.authority || repository.authority.includes('@') || repository.query || repository.fragment) { + throw new Error(localize('agentHost.invalidRepositoryUri', "Select a repository URL without credentials, a query, or a fragment.")); + } + let initial: ResolveSessionConfigResult; + try { + initial = await raceCancellationError(connection.resolveSessionConfig({ provider, config }), token); + } catch (error) { + if (error instanceof ProtocolError && error.code === JsonRpcErrorCodes.MethodNotFound) { + return undefined; + } + throw error; + } + const descriptor = readRepositorySessionConfig(initial.schema); + if (!descriptor) { + return undefined; + } + const url = repository.toString(); + const existing = config?.[descriptor.urlProperty]; + if (existing !== undefined && existing !== url) { + throw new Error(localize('agentHost.conflictingRepository', "The selected repository conflicts with the session configuration.")); + } + const requested = { ...initial.values, ...config, [descriptor.urlProperty]: url }; + const resolved = await raceCancellationError(connection.resolveSessionConfig({ provider, config: requested }), token); + const confirmed = readRepositorySessionConfig(resolved.schema); + if (!confirmed || confirmed.urlProperty !== descriptor.urlProperty || confirmed.revisionProperty !== descriptor.revisionProperty) { + throw new Error(localize('agentHost.repositoryConfigChanged', "The agent host changed its repository configuration while resolving the session.")); + } + return { ...resolved.values, ...requested }; +} + +/** Wait for opted-in repository initialization, preserving other sessions' existing lifecycle handling. */ +export function waitForRepositorySessionReady(subscription: IAgentSubscription, token: CancellationToken, expectedRepository?: URI, expectedConfig?: Readonly>): Promise { + return new Promise((resolve, reject) => { + const store = new DisposableStore(); + const fail = (error: unknown) => { + store.dispose(); + reject(error); + }; + const check = () => { + try { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const state = subscription.value; + if (state instanceof Error) { + throw state; + } + if (!state) { + return; + } + const repository = getRepositorySessionSource(state.config); + if (repository !== undefined || expectedRepository) { + if (state.lifecycle === SessionLifecycle.Creating) { + return; + } + if (state.lifecycle === SessionLifecycle.Failed) { + throw new Error(state.creationError?.message ?? localize('agentHost.repositoryCreationFailed', "The agent host could not prepare this repository session.")); + } + const revisionProperty = readRepositorySessionConfig(state.config?.schema)?.revisionProperty; + const expectedRevision = revisionProperty ? expectedConfig?.[revisionProperty] : undefined; + const actualRevision = revisionProperty ? state.config?.values[revisionProperty] : undefined; + if (state.lifecycle !== SessionLifecycle.Ready || !repository + || (expectedRepository && repository !== expectedRepository.toString()) + || (expectedRevision !== undefined && actualRevision !== expectedRevision) + || !Array.isArray(state.workingDirectories) || !state.workingDirectories.length + || state.workingDirectories.some(directory => typeof directory !== 'string' || !URI.parse(directory).scheme)) { + throw new Error(localize('agentHost.repositoryNotReady', "The agent host did not report a ready checkout for the selected repository.")); + } + } + store.dispose(); + resolve(state); + } catch (error) { + fail(error); + } + }; + store.add(subscription.onDidChange(check)); + if (subscription.onDidError) { + store.add(subscription.onDidError(fail)); + } + store.add(token.onCancellationRequested(check)); + check(); + }); +} 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 a73e3aa391e869..e0ad21821c0be2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -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'; @@ -50,6 +50,8 @@ import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuth 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, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AhpErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; +import { getRepositorySessionSource, resolveAgentHostRepositoryConfig, waitForRepositorySessionReady } from './agentHostRepositoryConfig.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readMessageSystemInitiatedLabel, 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 ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -932,6 +934,7 @@ class ActiveClientEntry extends Disposable { constructor( private readonly _scope: IAgentCustomizationScope, + readonly scopeRoots: readonly URI[], clientId: string, debounceDelay: number, private readonly _getSessionState: (backendSession: URI) => SessionState | undefined, @@ -1484,6 +1487,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // separate chat channel, so reading them before the chat // subscription lands would yield an empty history. await this._whenSubscriptionHydrated(sub, token); + await waitForRepositorySessionReady(sub, token); // A failed subscription surfaces as an `Error` value; rethrow it // so the real reason (e.g. the working directory no longer // exists) is logged and rendered instead of a generic message. @@ -1901,6 +1905,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'; @@ -2063,8 +2068,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!sub) { return undefined; } - if (sub.value !== undefined) { - return sub.value instanceof Error ? undefined : sub.value; + if (sub.value instanceof Error) { + return undefined; + } + if (sub.value !== undefined && getRepositorySessionSource(sub.value.config) === undefined) { + return sub.value; } // Snapshot is in flight. Pin the subscription with a fresh @@ -2081,7 +2089,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._whenSubscriptionHydrated(pinRef.object, token); const value = pinRef.object.value; this._logService.info(`[AgentHost] _readEagerlyCreatedSessionState: hydrated value=${value === undefined ? 'undefined' : value instanceof Error ? `error(${value.message})` : 'state'} cancelled=${token.isCancellationRequested} for ${resolvedSession.toString()}`); - return value instanceof Error ? undefined : value; + return value instanceof Error || value === undefined ? undefined : await waitForRepositorySessionReady(pinRef.object, token); } finally { pinRef.dispose(); } @@ -2323,15 +2331,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await entry.claim(backendSession, cancellationToken); } - private _ensureActiveClientEntry(sessionResource: URI): ActiveClientEntry { + private _ensureActiveClientEntry(sessionResource: URI, scopeRoots?: readonly URI[]): ActiveClientEntry { const existing = this._activeClientEntries.get(sessionResource); - if (existing) { + if (existing && (!scopeRoots || this._activeClientService.areScopeRootsEqual(existing.scopeRoots, scopeRoots))) { return existing; } + this._disposeActiveClientEntry(sessionResource); - const scope = this._activeClientService.acquireScope(this._config.sessionType, this._resolveCustomizationScopeRoots(sessionResource)); + const roots = scopeRoots ?? this._resolveCustomizationScopeRoots(sessionResource); + const scope = this._activeClientService.acquireScope(this._config.sessionType, roots); const entry = new ActiveClientEntry( scope, + roots, this._config.connection.clientId, AgentHostSessionHandler.ACTIVE_CLIENT_RECONCILIATION_DEBOUNCE_MS, backendSession => this._getSessionState(backendSession.toString()), @@ -5583,8 +5594,8 @@ 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, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void): Promise { - const workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); + private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, config?: Record, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void, cancellationToken: CancellationToken = CancellationToken.None): Promise { + let workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); const requestedSession = this._resolveSessionUri(sessionResource); const meta = this._provisionalService.getInitialSessionMetadata(sessionResource); @@ -5593,8 +5604,26 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC onFailureStage?.('authentication'); const protectedResources = await this._ensureRequiredAuthentication(model); + const requestedDirectory = this._resolveRequestedWorkingDirectory(sessionResource); + const defaultDirectory = this._config.connection.initializeResult.get()?.defaultDirectory; + const defaultScheme = defaultDirectory ? (URI.isUri(defaultDirectory) ? URI.revive(defaultDirectory) : URI.parse(defaultDirectory)).scheme : undefined; + let repository: URI | undefined; + if (requestedDirectory?.scheme === Schemas.https && defaultScheme !== Schemas.https) { + const repositoryConfig = await resolveAgentHostRepositoryConfig(this._config.connection, this._config.provider, requestedDirectory, config, cancellationToken); + if (repositoryConfig) { + config = repositoryConfig; + workingDirectories = undefined; + repository = requestedDirectory; + } else { + this._logService.info('[AgentHost] Repository session configuration is not advertised; retaining the host-selected directory behavior.'); + } + } + if (cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + const activeClientEntry = this._ensureActiveClientEntry(sessionResource); - await activeClientEntry.whenSettled(); + await raceCancellationError(activeClientEntry.whenSettled(), cancellationToken); const activeClient = this._getCurrentActiveClient(sessionResource); // Opt in to bring-up progress (chiefly the lazy first-use SDK download) @@ -5619,7 +5648,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } catch (err) { // If authentication is required (e.g. token expired), try interactive auth and retry once - if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { + if (repository && err instanceof ProtocolError && err.code === AhpErrorCodes.SessionAlreadyExists) { + session = requestedSession; + } else if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { onFailureStage?.('authentication'); this._logService.info('[AgentHost] Authentication required, prompting user...'); const authenticated = await this._config.resolveAuthentication(protectedResources); @@ -5666,10 +5697,19 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // subscription via `setError`, which fires `onDidError` but NOT // `onDidChange`, so an `onDidChange`-only wait would hang for the // full turn timeout (issue #5242). - await this._whenSubscriptionHydrated(newSub, CancellationToken.None); + await this._whenSubscriptionHydrated(newSub, cancellationToken); } - const rawState = this._requireRawSessionState(session.toString()); + const rawState = await waitForRepositorySessionReady(newSub, cancellationToken, repository, config); + if (getRepositorySessionSource(rawState.config) !== undefined) { + const roots = rawState.workingDirectories?.map(directory => this._config.connection.resourceUris.fromAgentHost(URI.parse(directory))) ?? []; + if (roots.some(root => root.scheme === Schemas.file) && !await this._ensureFoldersTrusted(roots)) { + throw new CancellationError(); + } + const entry = this._ensureActiveClientEntry(sessionResource, roots); + entry.attach(session, newSub); + await raceCancellationError(entry.whenSettled(), cancellationToken); + } const chatURI = this._resolveChatUriFromState(sessionResource, rawState); this._setChatURI(sessionResource, chatURI); const chatSub = this._ensureChatSubscription(session.toString(), chatURI); 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 e96c92f5e55b01..f175dc8eaee1a3 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 @@ -8,6 +8,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; 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 { CancellationError } from '../../../../../../base/common/errors.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -27,7 +28,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; @@ -39,9 +40,10 @@ import { toAgentWorkspaceContinuationMessageMeta } from '../../../../../../platf import { toAgentMergeMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.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 { AHP_NOT_FOUND, ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AhpErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.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, withMessageRequestHiddenFromTranscript, 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, type InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult, type InitializeResult, type ResolveSessionConfigResult } 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'; @@ -198,6 +200,10 @@ class MockAgentHostService extends mock() { private _nextId = 1; private readonly _sessions = new Map(); public createSessionCalls: IAgentCreateSessionConfig[] = []; + public resolveSessionConfigCalls: IAgentResolveSessionConfigParams[] = []; + public repositorySessionConfig?: ResolveSessionConfigResult; + public nextSessionLifecycle = SessionLifecycle.Ready; + public nextCreateSessionResponseError?: Error; public disposedSessions: URI[] = []; public failNextSubscriptionFor = new Set(); @@ -258,6 +264,13 @@ class MockAgentHostService extends mock() { return [...this._sessions.values()]; } + override async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + this.resolveSessionConfigCalls.push(params); + return this.repositorySessionConfig + ? { ...this.repositorySessionConfig, values: { ...this.repositorySessionConfig.values, ...params.config } } + : { schema: { type: 'object', properties: {} }, values: params.config ?? {} }; + } + override async createSession(config?: IAgentCreateSessionConfig): Promise { if (config) { this.createSessionCalls.push(config); @@ -279,12 +292,19 @@ class MockAgentHostService extends mock() { }; const state: SessionState = { ...this._withDefaultChatCatalog(createSessionState(summary), session.toString()), - lifecycle: SessionLifecycle.Ready, + lifecycle: this.nextSessionLifecycle, activeClients: [config.activeClient], + ...(this.repositorySessionConfig ? { config: { schema: this.repositorySessionConfig.schema, values: config.config ?? {} } } : {}), }; this.sessionStates.set(session.toString(), state); } this.nextResolvedWorkingDirectory = undefined; + this.nextSessionLifecycle = SessionLifecycle.Ready; + if (this.nextCreateSessionResponseError) { + const error = this.nextCreateSessionResponseError; + this.nextCreateSessionResponseError = undefined; + throw error; + } return session; } @@ -10946,6 +10966,148 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectories?.[0]?.toString(), URI.file('/custom/working/dir').toString()); })); + for (const alreadyExists of [false, true]) { + for (const hasDefaultDirectory of [false, true]) { + test(`repository session uses schema-selected config and reattaches after a lost response (${alreadyExists}, default directory ${hasDefaultDirectory})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); + const repository = URI.parse('https://example.com/owner/repo'); + const checkout = URI.file('/host/checkout'); + const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: 'checkout-plugin', uri: 'file:///checkout-plugin', name: 'Checkout plugin' }]; + disposables.add(seedActiveClient('repository-session', { customizations: constObservable(customizations) }, [checkout])); + if (hasDefaultDirectory) { + agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); + } + agentHostService.nextResolvedWorkingDirectory = checkout; + if (alreadyExists) { + agentHostService.nextCreateSessionResponseError = new ProtocolError(AhpErrorCodes.SessionAlreadyExists, 'Session already created'); + } + agentHostService.repositorySessionConfig = { + schema: { + type: 'object', + properties: { source: { type: 'string', title: 'Repository' } }, + repository: { urlProperty: 'source' }, + }, + values: {}, + }; + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', + agentId: 'repository-session', + sessionType: 'repository-session', + fullName: 'Test', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + resolveWorkingDirectory: () => repository, + })); + const resource = URI.from({ scheme: 'repository-session', path: '/new-repository' }); + const chat = await handler.provideChatSessionContent(resource, CancellationToken.None); + disposables.add(toDisposable(() => chat.dispose())); + const registered = chatAgentService.registeredAgents.get('repository-session'); + assert.ok(registered); + const turn = registered.impl.invoke(makeRequest({ agentId: 'repository-session', sessionResource: resource }), () => { }, [], CancellationToken.None); + await timeout(25); + const dispatch = agentHostService.turnActions[0]; + assert.ok(dispatch); + const started = dispatch.action as ITurnStartedAction; + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: dispatch.action, serverSeq: 2, origin: { clientId: agentHostService.clientId, clientSeq: dispatch.clientSeq } }); + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: ActionType.ChatTurnComplete, turnId: started.turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction, serverSeq: 3, origin: undefined }); + await turn; + const lastActiveClient = agentHostService.dispatchedActions.findLast(entry => entry.action.type === ActionType.SessionActiveClientSet)?.action; + assert.deepStrictEqual({ + config: agentHostService.createSessionCalls[0].config, + workingDirectories: agentHostService.createSessionCalls[0].workingDirectories, + discoveryDirectories: agentHostService.resolveSessionConfigCalls.map(call => call.workingDirectory), + customizations: lastActiveClient?.type === ActionType.SessionActiveClientSet ? lastActiveClient.activeClient.customizations : undefined, + }, { + config: { source: repository.toString() }, + workingDirectories: undefined, + discoveryDirectories: [undefined, undefined], + customizations, + }); + })); + } + } + + test('repository session does not send the first turn until the host publishes ready', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const repository = URI.parse('https://example.com/owner/repo'); + agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); + agentHostService.nextResolvedWorkingDirectory = URI.file('/host/checkout'); + agentHostService.nextSessionLifecycle = SessionLifecycle.Creating; + agentHostService.repositorySessionConfig = { + schema: { type: 'object', properties: { source: { type: 'string', title: 'Repository' } }, repository: { urlProperty: 'source' } }, + values: {}, + }; + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', + agentId: 'repository-ready', + sessionType: 'repository-ready', + fullName: 'Test', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + resolveWorkingDirectory: () => repository, + })); + const resource = URI.from({ scheme: 'repository-ready', path: '/new-repository' }); + const chat = await handler.provideChatSessionContent(resource, CancellationToken.None); + disposables.add(toDisposable(() => chat.dispose())); + const registered = chatAgentService.registeredAgents.get('repository-ready'); + assert.ok(registered); + const turn = registered.impl.invoke(makeRequest({ agentId: 'repository-ready', sessionResource: resource }), () => { }, [], CancellationToken.None); + await timeout(10); + const turnsBeforeReady = agentHostService.turnActions.length; + const backendSession = agentHostService.createSessionCalls[0].session; + assert.ok(backendSession); + agentHostService.fireAction({ channel: backendSession.toString(), action: { type: ActionType.SessionReady }, serverSeq: 1, origin: undefined }); + await timeout(10); + const dispatch = agentHostService.turnActions[0]; + assert.ok(dispatch); + const started = dispatch.action as ITurnStartedAction; + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: dispatch.action, serverSeq: 2, origin: { clientId: agentHostService.clientId, clientSeq: dispatch.clientSeq } }); + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: ActionType.ChatTurnComplete, turnId: started.turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction, serverSeq: 3, origin: undefined }); + await turn; + assert.deepStrictEqual({ turnsBeforeReady, turnsAfterReady: agentHostService.turnActions.length }, { turnsBeforeReady: 0, turnsAfterReady: 1 }); + })); + + test('repository session verifies trust for a newly prepared local checkout before starting a turn', async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const repository = URI.parse('https://example.com/owner/repo'); + const checkout = URI.file('/new-local-checkout'); + const trustRequests: string[] = []; + instantiationService.stub(IWorkspaceTrustRequestService, { + requestWorkspaceTrust: async () => true, + requestResourcesTrust: async options => { + trustRequests.push(options.uri.toString()); + return false; + }, + }); + agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); + agentHostService.nextResolvedWorkingDirectory = checkout; + agentHostService.repositorySessionConfig = { + schema: { type: 'object', properties: { source: { type: 'string', title: 'Repository' } }, repository: { urlProperty: 'source' } }, + values: {}, + }; + disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', + agentId: 'repository-trust', + sessionType: 'repository-trust', + fullName: 'Test', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + resolveWorkingDirectory: () => repository, + })); + const registered = chatAgentService.registeredAgents.get('repository-trust'); + assert.ok(registered); + await assert.rejects(registered.impl.invoke(makeRequest({ + agentId: 'repository-trust', + sessionResource: URI.from({ scheme: 'repository-trust', path: '/new-trust' }), + }), () => { }, [], CancellationToken.None), CancellationError); + assert.deepStrictEqual({ trustRequests, created: agentHostService.createSessionCalls.length, turns: agentHostService.turnActions.length }, { + trustRequests: [checkout.toString()], created: 1, turns: 0, + }); + }); + test('handler forwards request session config to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices( disposables, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts index 87419e8eabf7da..8f16e67c3ad4d2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts @@ -73,17 +73,33 @@ suite('AgentHostDownloadProgress', () => { ); }); - test('indeterminate download (no total) reports megabytes received', () => { + test('indeterminate progress preserves the host message without assuming byte units', () => { const { controller, progressService } = create(); controller.handleProgress(frame({ progressToken: 'codex', progress: 5 * 1024 * 1024, message: 'Downloading Codex Agent' })); assert.deepStrictEqual( progressService.opened.map(o => ({ title: o.title, steps: o.steps.map(s => s.message) })), - [{ title: 'Downloading Codex Agent', steps: ['5.0 MB'] }], + [{ title: 'Downloading Codex Agent', steps: ['Downloading Codex Agent'] }], ); }); + test('repository preparation can report indeterminate progress', () => { + const { controller, progressService } = create(); + controller.handleProgress(frame({ progressToken: 'repository', progress: 3, message: 'Preparing repository' })); + assert.deepStrictEqual(progressService.opened.map(o => ({ title: o.title, steps: o.steps.map(s => s.message) })), [ + { title: 'Preparing repository', steps: ['Preparing repository'] }, + ]); + }); + + test('unlabelled progress uses an operation-neutral fallback', () => { + const { controller, progressService } = create(); + controller.handleProgress(frame({ progressToken: 'preparation', progress: 1 })); + assert.deepStrictEqual(progressService.opened.map(o => ({ title: o.title, steps: o.steps.map(s => s.message) })), [ + { title: 'Preparing Session', steps: ['Working...'] }, + ]); + }); + test('no notification when AI features are disabled', () => { const { controller, progressService } = create(true); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts new file mode 100644 index 00000000000000..594fbbee8837c5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentConnection, IAgentResolveSessionConfigParams } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { JsonRpcErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; +import { ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; +import { SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { readRepositorySessionConfig, resolveAgentHostRepositoryConfig, waitForRepositorySessionReady } from '../../../browser/agentSessions/agentHost/agentHostRepositoryConfig.js'; + +const repository = URI.parse('https://example.com/owner/repo'); +const schema: SessionConfigSchema = { + type: 'object', + properties: { + source: { type: 'string', title: 'Repository' }, + branch: { type: 'string', title: 'Revision' }, + mode: { type: 'string', title: 'Mode' }, + }, + repository: { urlProperty: 'source', revisionProperty: 'branch' }, +}; + +suite('AgentHostRepositoryConfig', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function connectionWithResponses(responses: readonly (ResolveSessionConfigResult | Error)[]) { + const calls: IAgentResolveSessionConfigParams[] = []; + const connection = new class extends mock() { + override async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + const response = responses[calls.length]; + calls.push(params); + if (response instanceof Error) { + throw response; + } + assert.ok(response, 'unexpected configuration request'); + return response; + } + }(); + return { calls, connection }; + } + + test('uses advertised field names and preserves selected values and host defaults', async () => { + const h = connectionWithResponses([ + { schema, values: { mode: 'interactive' } }, + { schema, values: { mode: 'interactive', extra: 'host-default' } }, + ]); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { branch: 'main', mode: 'plan' }, CancellationToken.None); + assert.deepStrictEqual({ calls: h.calls, config }, { + calls: [ + { provider: 'provider', config: { branch: 'main', mode: 'plan' } }, + { provider: 'provider', config: { branch: 'main', mode: 'plan', source: repository.toString() } }, + ], + config: { mode: 'plan', branch: 'main', source: repository.toString(), extra: 'host-default' }, + }); + }); + + test('no descriptor preserves legacy host behavior', async () => { + const h = connectionWithResponses([{ schema: { type: 'object', properties: {} }, values: {} }]); + assert.strictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), undefined); + assert.strictEqual(h.calls.length, 1); + }); + + test('an older host without configuration discovery preserves legacy behavior', async () => { + const h = connectionWithResponses([new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Unsupported')]); + assert.strictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), undefined); + }); + + test('an advertised feature failing later is not treated as an unsupported host', async () => { + const error = new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Configuration became unavailable'); + const h = connectionWithResponses([{ schema, values: {} }, error]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), error); + }); + + for (const invalidSchema of [ + { ...schema, repository: { urlProperty: 'missing' } }, + { ...schema, repository: { urlProperty: 'source', revisionProperty: 'source' } }, + { ...schema, properties: { ...schema.properties, source: { type: 'string' as const, title: 'Repository', readOnly: true } } }, + { ...schema, properties: { ...schema.properties, source: { type: 'string' as const, title: 'Repository', sessionMutable: true } } }, + { ...schema, properties: { ...schema.properties, source: { type: 'boolean' as const, title: 'Repository' } } }, + ]) { + test(`rejects an invalid advertised descriptor (${JSON.stringify(invalidSchema.repository)} ${JSON.stringify(invalidSchema.properties.source)})`, () => { + assert.throws(() => readRepositorySessionConfig(invalidSchema), /invalid repository configuration/); + }); + } + + test('does not silently replace an explicitly configured repository', async () => { + const h = connectionWithResponses([{ schema, values: {} }]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { source: 'https://example.com/another/repo' }, CancellationToken.None), /conflicts/); + }); + + test('does not send credential-bearing repository URLs', async () => { + const h = connectionWithResponses([]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', URI.parse('https://user@example.com/owner/repo'), undefined, CancellationToken.None), /without credentials/); + assert.deepStrictEqual(h.calls, []); + }); + + test('does not query a cancelled operation', async () => { + const h = connectionWithResponses([]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.Cancelled), CancellationError); + assert.deepStrictEqual(h.calls, []); + }); + + function session(lifecycle: SessionLifecycle, withRepository = true): SessionState { + return upcastPartial({ + lifecycle, + workingDirectories: lifecycle === SessionLifecycle.Ready ? ['file:///checkout/repo'] : undefined, + config: withRepository ? { schema, values: { source: repository.toString() } } : undefined, + }); + } + + function subscription(initial: SessionState) { + let value: SessionState | Error = initial; + const changes = store.add(new Emitter()); + const errors = store.add(new Emitter()); + const sub: IAgentSubscription = { + get value() { return value; }, + get verifiedValue() { return value instanceof Error ? undefined : value; }, + onDidChange: changes.event, + onDidError: errors.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + return { + sub, + set(state: SessionState) { value = state; changes.fire(state); }, + fail(error: Error) { value = error; errors.fire(error); }, + hasListeners: () => changes.hasListeners() || errors.hasListeners(), + }; + } + + test('waits for ready directory state, not merely the creation acknowledgement', async () => { + const h = subscription(session(SessionLifecycle.Creating)); + let resolved = false; + const result = waitForRepositorySessionReady(h.sub, CancellationToken.None, repository).then(state => { resolved = true; return state; }); + await Promise.resolve(); + const beforeReady = resolved; + const ready = session(SessionLifecycle.Ready); + h.set(ready); + assert.deepStrictEqual({ beforeReady, state: await result, hasListeners: h.hasListeners() }, { beforeReady: false, state: ready, hasListeners: false }); + }); + + test('a joining client also waits for repository preparation', async () => { + const h = subscription(session(SessionLifecycle.Creating)); + const result = waitForRepositorySessionReady(h.sub, CancellationToken.None); + const ready = session(SessionLifecycle.Ready); + h.set(ready); + assert.strictEqual(await result, ready); + }); + + test('propagates a shared creation failure instead of sending a turn', async () => { + const h = subscription(session(SessionLifecycle.Creating)); + const result = waitForRepositorySessionReady(h.sub, CancellationToken.None); + h.set({ ...session(SessionLifecycle.Failed), creationError: { errorType: 'repository', message: 'Repository access denied' } }); + await assert.rejects(result, /Repository access denied/); + assert.strictEqual(h.hasListeners(), false); + }); + + test('propagates subscription failure without waiting forever', async () => { + const h = subscription(session(SessionLifecycle.Creating)); + const result = waitForRepositorySessionReady(h.sub, CancellationToken.None); + h.fail(new Error('Connection closed')); + await assert.rejects(result, /Connection closed/); + assert.strictEqual(h.hasListeners(), false); + }); + + test('cancels the local readiness wait without disposing the shared session', async () => { + const h = subscription(session(SessionLifecycle.Creating)); + const cts = store.add(new CancellationTokenSource()); + const result = waitForRepositorySessionReady(h.sub, cts.token); + cts.cancel(); + await assert.rejects(result, CancellationError); + assert.strictEqual(h.hasListeners(), false); + }); + + test('a claimed ready repository must have resolved working directories', async () => { + const h = subscription({ ...session(SessionLifecycle.Ready), workingDirectories: [] }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None), /did not report a ready checkout/); + }); + + test('lost-response recovery must match the originally requested repository', async () => { + const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { source: 'https://example.com/another/repo' } } }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository), /did not report a ready checkout/); + }); + + test('lost-response recovery must also preserve an explicitly requested revision', async () => { + const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { source: repository.toString(), branch: 'other' } } }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, { branch: 'main' }), /did not report a ready checkout/); + }); + + test('keeps the existing lifecycle behavior for non-repository sessions', async () => { + const state = session(SessionLifecycle.Creating, false); + const h = subscription(state); + assert.strictEqual(await waitForRepositorySessionReady(h.sub, CancellationToken.None), state); + assert.strictEqual(h.hasListeners(), false); + }); +}); From c8342001126ff6494418068cb17516dd88d0650a Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 15 Sep 2026 18:50:14 -0700 Subject: [PATCH 2/4] agentHost: Honor repository-dependent configuration defaults Use the host's re-resolved defaults after selecting a repository, rather than restoring defaults from the previous context. Preserve explicit user selections and add a regression test for removed and changed defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentHost/agentHostRepositoryConfig.ts | 2 +- .../agentSessions/agentHostRepositoryConfig.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts index 50075863c109a7..ca1495bb6aa3ff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts @@ -80,7 +80,7 @@ export async function resolveAgentHostRepositoryConfig(connection: IAgentConnect if (!confirmed || confirmed.urlProperty !== descriptor.urlProperty || confirmed.revisionProperty !== descriptor.revisionProperty) { throw new Error(localize('agentHost.repositoryConfigChanged', "The agent host changed its repository configuration while resolving the session.")); } - return { ...resolved.values, ...requested }; + return { ...resolved.values, ...config, [descriptor.urlProperty]: url }; } /** Wait for opted-in repository initialization, preserving other sessions' existing lifecycle handling. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts index 594fbbee8837c5..9c95125b08fbe0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts @@ -70,6 +70,15 @@ suite('AgentHostRepositoryConfig', () => { assert.strictEqual(h.calls.length, 1); }); + test('repository-dependent defaults replace the initial context defaults', async () => { + const h = connectionWithResponses([ + { schema, values: { branch: 'previous-context', obsolete: 'old-default' } }, + { schema, values: { branch: 'repository-default', source: repository.toString() } }, + ]); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None); + assert.deepStrictEqual(config, { branch: 'repository-default', source: repository.toString() }); + }); + test('an older host without configuration discovery preserves legacy behavior', async () => { const h = connectionWithResponses([new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Unsupported')]); assert.strictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), undefined); From ec73c315f1d46317445a349af2f53dad4d0eb86f Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 17 Sep 2026 16:15:39 -0700 Subject: [PATCH 3/4] agentHost: Standardize repository source configuration Use repositorySource and repositoryRevision from advertised session configuration instead of a field-name descriptor. Preserve directory-only compatibility and requested intent through readiness and lost-response recovery, and reject unsupported explicit inputs. Sync the AHP contract from b6a62eba9b67cbe3252682e9d6a4255d3e1175c6. Keep the local integration UI patch outside this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79731f48-d288-483f-8809-136aa141d1eb --- .../agentHost/common/sessionConfigKeys.ts | 8 +- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-root/commands.ts | 19 +- .../protocol/channels-session/commands.ts | 31 ++-- .../state/protocol/channels-session/state.ts | 47 ++--- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 4 +- .../agentHost/agentHostRepositoryConfig.ts | 80 +++++---- .../agentHostChatContribution.test.ts | 11 +- .../agentHostRepositoryConfig.test.ts | 169 +++++++++++++++--- 9 files changed, 250 insertions(+), 121 deletions(-) diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index 6d504040be11bc..a136e1b77b576a 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -7,8 +7,8 @@ * Well-known keys used in the agent-host configuration value bag. * * The Agent Host Protocol's config schema is intentionally generic — agents - * are free to advertise any property names. These constants capture the - * names that the platform itself consumes (e.g. {@link SessionConfigKey.AutoApprove} + * can advertise provider-specific property names alongside standardized inputs. + * These constants capture the names that the platform itself consumes (e.g. {@link SessionConfigKey.AutoApprove} * drives tool auto-approval) or that clients interpret via convention * (e.g. {@link SessionConfigKey.Branch}, {@link SessionConfigKey.Isolation}). * @@ -27,6 +27,10 @@ export const enum SessionConfigKey { Isolation = 'isolation', /** `'branch'` — host-owned base branch to work from. */ Branch = 'branch', + /** Standard AHP source URI for repository-backed session creation. */ + RepositorySource = 'repositorySource', + /** Standard AHP requested repository revision, separate from the working branch. */ + RepositoryRevision = 'repositoryRevision', /** `'mode'` — agent execution mode (interactive / plan / autopilot). */ Mode = 'mode', /** `'worktreeBranchPrefix'` — host-owned prefix for the worktree branch name. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 73773076e1a461..fa2ba84b23a7a1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -5eadbe33 +b6a62eba diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts index d13348122325b2..c82e678c096314 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts @@ -13,7 +13,7 @@ import type { SessionSummary, SessionConfigSchema } from '../channels-session/st // Re-export schema types so the legacy `commands.ts` aggregator continues to // expose them from the same import path. export type { ConfigPropertySchema, ConfigSchema } from '../common/state.js'; -export type { RepositorySessionConfig, SessionConfigPropertySchema, SessionConfigSchema } from '../channels-session/state.js'; +export type { SessionConfigPropertySchema, SessionConfigSchema } from '../channels-session/state.js'; // ─── listSessions ──────────────────────────────────────────────────────────── @@ -79,9 +79,12 @@ export interface ListSessionsResult extends PaginatedResult { * the full current property set (not a delta). The returned `values` contain * server-resolved defaults to pass to `createSession`. * - * Repository-backed creation is advertised by `schema.repository`. Resolving - * that schema or its values MUST NOT clone or prepare a repository; preparation - * belongs to `createSession`. + * Repository-backed creation is advertised by a valid + * `schema.properties.repositorySource`, with optional + * `schema.properties.repositoryRevision`; see {@link SessionConfigSchema}. + * Values use those fixed keys in `config`. Resolving the schema or its values, + * including discovery without a working directory, MUST NOT clone or prepare + * a repository; preparation belongs to `createSession`. * * @category Commands * @method resolveSessionConfig @@ -134,7 +137,13 @@ export interface ResolveSessionConfigParams extends BaseParams { provider?: string; /** Working directory for the session */ workingDirectory?: URI; - /** Current user-filled configuration values */ + /** + * Current user-filled configuration values. Repository intent uses + * `repositorySource` and optional `repositoryRevision` only when advertised + * by the session config schema. Invalid or unsupported repository input MUST + * produce `InvalidParams` (`-32602`), not silently select directory/default + * behavior. + */ config?: Record; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index d38b5e089850a6..bffa6a5c0dc26b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -23,12 +23,14 @@ import type { MessageAttachment } from '../channels-chat/state.js'; * updates. The server also broadcasts a `root/sessionAdded` notification to all * clients. * - * For repository intent advertised by {@link RepositorySessionConfig}, the - * host MUST authorize the request before repository side effects and prepare - * the repository before executing turns. It MUST publish the requested intent - * in {@link SessionState.config} and any resolved `workingDirectories` before - * `session/ready` or `session/creationFailed`. Clients recover the outcome from - * session state, not progress notifications. + * For repository intent advertised by {@link SessionConfigSchema.properties}, + * the host MUST authorize the request before repository side effects and + * prepare the repository before executing turns. It MUST publish the requested + * `repositorySource` and optional `repositoryRevision` in + * {@link SessionState.config} from the initial `creating` snapshot and retain + * them through `ready` or `failed`. Any resolved `workingDirectories` MUST be + * published before `session/ready` or `session/creationFailed`. Clients recover + * the outcome from session state, not progress notifications. * * @category Commands * @method createSession @@ -72,16 +74,21 @@ export interface CreateSessionParams extends BaseParams { * after the session has started. * * A non-empty list and repository intent in `config` are mutually exclusive. - * A repository URI is not a working-directory URI. + * A repository URI identifies the source, not a working-directory URI; one + * source may produce multiple directories. */ workingDirectories?: URI[]; /** - * Agent-specific configuration values collected via `resolveSessionConfig`. + * Session configuration values collected via `resolveSessionConfig`. * Keys and values correspond to the schema returned by the server. - * Repository intent uses only the properties identified by the advertised - * {@link SessionConfigSchema.repository} descriptor. A revision without a - * repository URI is invalid. Omitting repository intent preserves existing - * directory/default behavior. + * Repository intent uses the standard `repositorySource` and optional + * `repositoryRevision` keys only when advertised by + * {@link SessionConfigSchema.properties}. Values MUST be non-empty strings; + * the source MUST be a credential-free repository URI. A revision without a + * source, unsupported input, or conflicting directories MUST produce + * `InvalidParams` (`-32602`), not silently fall back. Omitting repository + * intent preserves existing directory/default behavior. Other keys remain + * host-defined. */ config?: Record; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index bdcd4a7295741f..43fb11008c4dd9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -176,8 +176,9 @@ export interface SessionState extends SessionMetadata { defaultChat?: URI; /** * Session configuration schema and current values. For repository-backed - * creation, this includes the advertised repository descriptor and requested - * intent, so joining and reconnecting clients can recover it from state. + * creation, this includes the advertised standard properties and requested + * `repositorySource` and optional `repositoryRevision` values throughout + * `creating`, `ready`, and `failed`, so clients can recover intent from state. */ config?: SessionConfigState; /** @@ -560,32 +561,20 @@ export interface SessionConfigPropertySchema extends ConfigPropertySchema { } /** - * Opt-in descriptor for preparing one repository during session creation. - * - * Property ids are host-chosen and MUST name distinct entries in - * {@link SessionConfigSchema.properties}. Each referenced property MUST have - * `type: 'string'` and MUST NOT have `readOnly: true` or `sessionMutable: true`. - * Clients MUST use these ids rather than hardcoding repository field names. + * A JSON Schema object describing available session configuration metadata. * - * Values travel through `resolveSessionConfig.config` and `createSession.config`, - * not a separate command or `_meta`. Schema discovery MUST NOT clone or prepare - * a repository. The host accepts repository intent only when this descriptor - * is advertised. + * Repository-backed creation uses the standard optional config keys + * `repositorySource` (a credential-free repository URI) and + * `repositoryRevision` (a branch, tag, or commit). Support is advertised by + * `properties.repositorySource`; `properties.repositoryRevision` MUST NOT be + * advertised without it. Each advertised property MUST have `type: 'string'` + * and MUST NOT have `readOnly: true` or `sessionMutable: true`. * - * @category Session Config Types - */ -export interface RepositorySessionConfig { - /** Property id for a credential-free repository URI. */ - urlProperty: string; - /** - * Property id for an optional branch, tag, or commit revision. - * A revision value without a repository URI is invalid. - */ - revisionProperty?: string; -} - -/** - * A JSON Schema object describing available session configuration metadata. + * The host MUST NOT accept repository inputs unless their corresponding + * properties are advertised. Values travel through `resolveSessionConfig.config` + * and `createSession.config`; schema discovery MUST NOT prepare a repository. + * Neither key is globally required. Without repository intent, existing + * directory/default behavior is unchanged. Other property ids remain host-defined. * * @category Session Config Types */ @@ -596,12 +585,6 @@ export interface SessionConfigSchema { properties: Record; /** JSON Schema: list of required property ids */ required?: string[]; - /** - * Opt-in capability for repository-backed creation using existing config - * properties. The descriptor does not itself require a repository value. - * Without repository intent, existing directory/default behavior is unchanged. - */ - repository?: RepositorySessionConfig; } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 3b5179b905a2e0..dc9aab15b97a77 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -63,9 +63,9 @@ Remote session and chat resources preserve connection-specific routing identity ### Repository-backed session creation -A repository selection is intent, not a host filesystem directory. When the host cannot address the selected repository URI as a directory, the client discovers its session configuration. The optional standard `SessionConfigSchema.repository` descriptor identifies the declared URL and revision properties; the client passes those values through ordinary session creation without invoking a vendor cloning method. Hosts that do not advertise the descriptor retain the existing directory-selection behavior. +A repository selection is intent, not a host filesystem directory. When the host cannot address the selected repository URI as a directory, the client discovers its session configuration. The host advertises the standard `repositorySource` input, and optionally `repositoryRevision`, as creation-writable string properties in its configuration schema. The client passes the source URI and requested revision under those fixed keys in ordinary session creation without invoking a vendor cloning method. Hosts that do not advertise the source input retain the existing directory-selection behavior; explicitly supplied repository inputs must not be silently dropped. -The host owns checkout preparation and publishes its outcome through session state. A repository-backed session must reach `ready` with its selected repository and resolved directories before the client sends a turn. The client rebinds workspace-scoped customizations to those directories, propagates creation failures and allows a cancelled local wait to stop without disposing shared host resources. Reconnection observes the existing session; a lost creation reply must not cause an unrelated session to be accepted under the same URI. +The host owns checkout preparation and publishes its outcome through session state. Requested source and revision remain in configuration values, separate from the resulting working directories. A source can have multiple working directories or different worktrees across sessions. A repository-backed session must reach `ready` with its selected repository and resolved directories before the client sends a turn. The client rebinds workspace-scoped customizations to those directories, propagates creation failures and allows a cancelled local wait to stop without disposing shared host resources. Reconnection observes the existing session; a lost creation reply must not cause an unrelated session to be accepted under the same URI. This is an optional protocol capability, not a requirement that every host use Git or materialize a local directory. Directory-based requests, existing sessions and hosts without repository configuration keep their existing behavior. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts index ca1495bb6aa3ff..5b8a14e09ae73b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts @@ -10,41 +10,49 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { JsonRpcErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; import { ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { RepositorySessionConfig, SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; +import { SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; import { SessionConfigState, SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -/** Read the standard repository intent descriptor without guessing configuration property names. */ -export function readRepositorySessionConfig(schema: SessionConfigSchema | undefined): RepositorySessionConfig | undefined { - const descriptor = schema?.repository; - if (descriptor === undefined) { - return undefined; - } +/** Validate the standard repository inputs advertised in the configuration schema. */ +export function supportsRepositorySessionConfig(schema: SessionConfigSchema | undefined): boolean { const properties = schema?.properties; - const isInput = (property: string) => properties && Object.hasOwn(properties, property) - && properties[property]?.type === 'string' && properties[property].readOnly !== true && properties[property].sessionMutable !== true; - if (!descriptor || typeof descriptor !== 'object' - || typeof descriptor.urlProperty !== 'string' || !descriptor.urlProperty || !isInput(descriptor.urlProperty) - || (descriptor.revisionProperty !== undefined && (typeof descriptor.revisionProperty !== 'string' - || !descriptor.revisionProperty || descriptor.revisionProperty === descriptor.urlProperty || !isInput(descriptor.revisionProperty)))) { + const hasSource = properties && Object.hasOwn(properties, SessionConfigKey.RepositorySource); + const hasRevision = properties && Object.hasOwn(properties, SessionConfigKey.RepositoryRevision); + if (!hasSource && !hasRevision) { + return false; + } + const isInput = (property: string) => properties?.[property]?.type === 'string' + && properties[property].readOnly !== true && properties[property].sessionMutable !== true; + if (!hasSource || !isInput(SessionConfigKey.RepositorySource) || (hasRevision && !isInput(SessionConfigKey.RepositoryRevision))) { throw new Error(localize('agentHost.invalidRepositoryConfig', "The agent host advertised an invalid repository configuration.")); } - return descriptor; + return true; } -/** Read repository intent published in session state using the host's descriptor. */ +/** Read the standard repository source and validate its optional revision. */ export function getRepositorySessionSource(config: SessionConfigState | undefined): string | undefined { - const descriptor = readRepositorySessionConfig(config?.schema); - const value = descriptor && config?.values[descriptor.urlProperty]; - if (value === undefined) { + const supported = supportsRepositorySessionConfig(config?.schema); + const value = config?.values[SessionConfigKey.RepositorySource]; + const revision = config?.values[SessionConfigKey.RepositoryRevision]; + if (value === undefined && revision === undefined) { return undefined; } - if (typeof value !== 'string' || !value) { + if (!supported || typeof value !== 'string' || !value.trim()) { throw new Error(localize('agentHost.invalidRepositoryValue', "The agent host returned an invalid repository selection.")); } + if (revision !== undefined) { + if (!config || !Object.hasOwn(config.schema.properties, SessionConfigKey.RepositoryRevision)) { + throw new Error(localize('agentHost.unsupportedRepositoryRevision', "The agent host does not advertise repository revision selection.")); + } + if (typeof revision !== 'string' || !revision.trim()) { + throw new Error(localize('agentHost.invalidRepositoryRevision', "The repository revision must be a nonempty string.")); + } + } return value; } @@ -56,31 +64,36 @@ export async function resolveAgentHostRepositoryConfig(connection: IAgentConnect if (!repository.authority || repository.authority.includes('@') || repository.query || repository.fragment) { throw new Error(localize('agentHost.invalidRepositoryUri', "Select a repository URL without credentials, a query, or a fragment.")); } + const source = repository.toString(); + const existing = config?.[SessionConfigKey.RepositorySource]; + if (existing !== undefined && existing !== source) { + throw new Error(localize('agentHost.conflictingRepository', "The selected repository conflicts with the session configuration.")); + } + const hasExplicitRepositoryConfig = existing !== undefined || config?.[SessionConfigKey.RepositoryRevision] !== undefined; let initial: ResolveSessionConfigResult; try { initial = await raceCancellationError(connection.resolveSessionConfig({ provider, config }), token); } catch (error) { - if (error instanceof ProtocolError && error.code === JsonRpcErrorCodes.MethodNotFound) { + if (error instanceof ProtocolError && error.code === JsonRpcErrorCodes.MethodNotFound && !hasExplicitRepositoryConfig) { return undefined; } throw error; } - const descriptor = readRepositorySessionConfig(initial.schema); - if (!descriptor) { + if (!supportsRepositorySessionConfig(initial.schema)) { + if (hasExplicitRepositoryConfig) { + throw new Error(localize('agentHost.unsupportedRepositoryConfig', "The agent host does not advertise repository-backed session creation.")); + } return undefined; } - const url = repository.toString(); - const existing = config?.[descriptor.urlProperty]; - if (existing !== undefined && existing !== url) { - throw new Error(localize('agentHost.conflictingRepository', "The selected repository conflicts with the session configuration.")); - } - const requested = { ...initial.values, ...config, [descriptor.urlProperty]: url }; + const requested = { ...initial.values, ...config, [SessionConfigKey.RepositorySource]: source }; + getRepositorySessionSource({ schema: initial.schema, values: requested }); const resolved = await raceCancellationError(connection.resolveSessionConfig({ provider, config: requested }), token); - const confirmed = readRepositorySessionConfig(resolved.schema); - if (!confirmed || confirmed.urlProperty !== descriptor.urlProperty || confirmed.revisionProperty !== descriptor.revisionProperty) { + if (!supportsRepositorySessionConfig(resolved.schema)) { throw new Error(localize('agentHost.repositoryConfigChanged', "The agent host changed its repository configuration while resolving the session.")); } - return { ...resolved.values, ...config, [descriptor.urlProperty]: url }; + const values = { ...resolved.values, ...config, [SessionConfigKey.RepositorySource]: source }; + getRepositorySessionSource({ schema: resolved.schema, values }); + return values; } /** Wait for opted-in repository initialization, preserving other sessions' existing lifecycle handling. */ @@ -111,9 +124,8 @@ export function waitForRepositorySessionReady(subscription: IAgentSubscription { for (const alreadyExists of [false, true]) { for (const hasDefaultDirectory of [false, true]) { - test(`repository session uses schema-selected config and reattaches after a lost response (${alreadyExists}, default directory ${hasDefaultDirectory})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test(`repository session uses standard config and reattaches after a lost response (${alreadyExists}, default directory ${hasDefaultDirectory})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); const repository = URI.parse('https://example.com/owner/repo'); const checkout = URI.file('/host/checkout'); @@ -10984,8 +10984,7 @@ suite('AgentHostChatContribution', () => { agentHostService.repositorySessionConfig = { schema: { type: 'object', - properties: { source: { type: 'string', title: 'Repository' } }, - repository: { urlProperty: 'source' }, + properties: { repositorySource: { type: 'string', title: 'Repository' } }, }, values: {}, }; @@ -11019,7 +11018,7 @@ suite('AgentHostChatContribution', () => { discoveryDirectories: agentHostService.resolveSessionConfigCalls.map(call => call.workingDirectory), customizations: lastActiveClient?.type === ActionType.SessionActiveClientSet ? lastActiveClient.activeClient.customizations : undefined, }, { - config: { source: repository.toString() }, + config: { repositorySource: repository.toString() }, workingDirectories: undefined, discoveryDirectories: [undefined, undefined], customizations, @@ -11035,7 +11034,7 @@ suite('AgentHostChatContribution', () => { agentHostService.nextResolvedWorkingDirectory = URI.file('/host/checkout'); agentHostService.nextSessionLifecycle = SessionLifecycle.Creating; agentHostService.repositorySessionConfig = { - schema: { type: 'object', properties: { source: { type: 'string', title: 'Repository' } }, repository: { urlProperty: 'source' } }, + schema: { type: 'object', properties: { repositorySource: { type: 'string', title: 'Repository' } } }, values: {}, }; const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -11084,7 +11083,7 @@ suite('AgentHostChatContribution', () => { agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); agentHostService.nextResolvedWorkingDirectory = checkout; agentHostService.repositorySessionConfig = { - schema: { type: 'object', properties: { source: { type: 'string', title: 'Repository' } }, repository: { urlProperty: 'source' } }, + schema: { type: 'object', properties: { repositorySource: { type: 'string', title: 'Repository' } } }, values: {}, }; disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts index 9c95125b08fbe0..ae98c92d868726 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts @@ -15,19 +15,23 @@ import { IAgentSubscription } from '../../../../../../platform/agentHost/common/ import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { JsonRpcErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; import { ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; +import { SessionConfigPropertySchema, SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; import { SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { readRepositorySessionConfig, resolveAgentHostRepositoryConfig, waitForRepositorySessionReady } from '../../../browser/agentSessions/agentHost/agentHostRepositoryConfig.js'; +import { getRepositorySessionSource, resolveAgentHostRepositoryConfig, supportsRepositorySessionConfig, waitForRepositorySessionReady } from '../../../browser/agentSessions/agentHost/agentHostRepositoryConfig.js'; const repository = URI.parse('https://example.com/owner/repo'); const schema: SessionConfigSchema = { type: 'object', properties: { - source: { type: 'string', title: 'Repository' }, - branch: { type: 'string', title: 'Revision' }, + repositorySource: { type: 'string', title: 'Repository' }, + repositoryRevision: { type: 'string', title: 'Revision' }, + branch: { type: 'string', title: 'Working branch' }, mode: { type: 'string', title: 'Mode' }, }, - repository: { urlProperty: 'source', revisionProperty: 'branch' }, +}; +const sourceOnlySchema: SessionConfigSchema = { + type: 'object', + properties: { repositorySource: { type: 'string', title: 'Repository' } }, }; suite('AgentHostRepositoryConfig', () => { @@ -49,22 +53,22 @@ suite('AgentHostRepositoryConfig', () => { return { calls, connection }; } - test('uses advertised field names and preserves selected values and host defaults', async () => { + test('uses standard input names and preserves selected values and host defaults', async () => { const h = connectionWithResponses([ { schema, values: { mode: 'interactive' } }, { schema, values: { mode: 'interactive', extra: 'host-default' } }, ]); - const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { branch: 'main', mode: 'plan' }, CancellationToken.None); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: 'main', branch: 'feature', mode: 'plan' }, CancellationToken.None); assert.deepStrictEqual({ calls: h.calls, config }, { calls: [ - { provider: 'provider', config: { branch: 'main', mode: 'plan' } }, - { provider: 'provider', config: { branch: 'main', mode: 'plan', source: repository.toString() } }, + { provider: 'provider', config: { repositoryRevision: 'main', branch: 'feature', mode: 'plan' } }, + { provider: 'provider', config: { repositoryRevision: 'main', branch: 'feature', mode: 'plan', repositorySource: repository.toString() } }, ], - config: { mode: 'plan', branch: 'main', source: repository.toString(), extra: 'host-default' }, + config: { mode: 'plan', repositoryRevision: 'main', branch: 'feature', repositorySource: repository.toString(), extra: 'host-default' }, }); }); - test('no descriptor preserves legacy host behavior', async () => { + test('an unadvertised source input preserves legacy host behavior', async () => { const h = connectionWithResponses([{ schema: { type: 'object', properties: {} }, values: {} }]); assert.strictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), undefined); assert.strictEqual(h.calls.length, 1); @@ -73,10 +77,30 @@ suite('AgentHostRepositoryConfig', () => { test('repository-dependent defaults replace the initial context defaults', async () => { const h = connectionWithResponses([ { schema, values: { branch: 'previous-context', obsolete: 'old-default' } }, - { schema, values: { branch: 'repository-default', source: repository.toString() } }, + { schema, values: { branch: 'repository-default', repositorySource: repository.toString() } }, ]); const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None); - assert.deepStrictEqual(config, { branch: 'repository-default', source: repository.toString() }); + assert.deepStrictEqual(config, { branch: 'repository-default', repositorySource: repository.toString() }); + }); + + test('accepts a source input without optional revision support', async () => { + const h = connectionWithResponses([ + { schema: sourceOnlySchema, values: {} }, + { schema: sourceOnlySchema, values: {} }, + ]); + assert.deepStrictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), { + repositorySource: repository.toString(), + }); + }); + + test('host-specific field names do not advertise the standard capability', () => { + assert.strictEqual(supportsRepositorySessionConfig({ + type: 'object', + properties: { + source: { type: 'string', title: 'Source' }, + repositoryUrl: { type: 'string', title: 'Repository' }, + }, + }), false); }); test('an older host without configuration discovery preserves legacy behavior', async () => { @@ -90,21 +114,78 @@ suite('AgentHostRepositoryConfig', () => { await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), error); }); - for (const invalidSchema of [ - { ...schema, repository: { urlProperty: 'missing' } }, - { ...schema, repository: { urlProperty: 'source', revisionProperty: 'source' } }, - { ...schema, properties: { ...schema.properties, source: { type: 'string' as const, title: 'Repository', readOnly: true } } }, - { ...schema, properties: { ...schema.properties, source: { type: 'string' as const, title: 'Repository', sessionMutable: true } } }, - { ...schema, properties: { ...schema.properties, source: { type: 'boolean' as const, title: 'Repository' } } }, + for (const property of ['repositorySource', 'repositoryRevision']) { + for (const invalidProperty of [ + { type: 'string', title: 'Repository input', readOnly: true }, + { type: 'string', title: 'Repository input', sessionMutable: true }, + { type: 'boolean', title: 'Repository input' }, + ] satisfies SessionConfigPropertySchema[]) { + test(`rejects an invalid standard input (${property}, ${JSON.stringify(invalidProperty)})`, () => { + assert.throws(() => supportsRepositorySessionConfig({ + ...schema, + properties: { ...schema.properties, [property]: invalidProperty }, + }), /invalid repository configuration/); + }); + } + } + + test('rejects revision support without a source input', () => { + assert.throws(() => supportsRepositorySessionConfig({ + type: 'object', + properties: { repositoryRevision: { type: 'string', title: 'Revision' } }, + }), /invalid repository configuration/); + }); + + for (const config of [ + { repositorySource: repository.toString() }, + { repositoryRevision: 'main' }, ]) { - test(`rejects an invalid advertised descriptor (${JSON.stringify(invalidSchema.repository)} ${JSON.stringify(invalidSchema.properties.source)})`, () => { - assert.throws(() => readRepositorySessionConfig(invalidSchema), /invalid repository configuration/); + test(`does not discard explicit inputs on an unsupported host (${JSON.stringify(config)})`, async () => { + const h = connectionWithResponses([{ schema: { type: 'object', properties: {} }, values: {} }]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, config, CancellationToken.None), /does not advertise/); + }); + + test(`does not discard explicit inputs when discovery is unsupported (${JSON.stringify(config)})`, async () => { + const error = new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Unsupported'); + const h = connectionWithResponses([error]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, config, CancellationToken.None), error); + }); + } + + test('rejects a requested revision that the host does not advertise', async () => { + const h = connectionWithResponses([{ schema: sourceOnlySchema, values: {} }]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: 'main' }, CancellationToken.None), /does not advertise repository revision/); + assert.strictEqual(h.calls.length, 1); + }); + + test('fails if source support disappears during resolution', async () => { + const h = connectionWithResponses([ + { schema, values: {} }, + { schema: { type: 'object', properties: {} }, values: {} }, + ]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), /changed its repository configuration/); + }); + + test('fails if support for a requested revision disappears during resolution', async () => { + const h = connectionWithResponses([ + { schema, values: {} }, + { schema: sourceOnlySchema, values: {} }, + ]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: 'main' }, CancellationToken.None), /does not advertise repository revision/); + }); + + for (const revision of [null, 17, '', ' ']) { + test(`rejects an invalid explicit revision (${JSON.stringify(revision)})`, async () => { + const h = connectionWithResponses([{ schema, values: {} }]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: revision }, CancellationToken.None), /nonempty string/); + assert.strictEqual(h.calls.length, 1); }); } test('does not silently replace an explicitly configured repository', async () => { - const h = connectionWithResponses([{ schema, values: {} }]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { source: 'https://example.com/another/repo' }, CancellationToken.None), /conflicts/); + const h = connectionWithResponses([]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositorySource: 'https://example.com/another/repo' }, CancellationToken.None), /conflicts/); + assert.deepStrictEqual(h.calls, []); }); test('does not send credential-bearing repository URLs', async () => { @@ -119,11 +200,28 @@ suite('AgentHostRepositoryConfig', () => { assert.deepStrictEqual(h.calls, []); }); + for (const source of [null, 17, '', ' ']) { + test(`rejects an invalid source in session state (${JSON.stringify(source)})`, () => { + assert.throws(() => getRepositorySessionSource({ schema, values: { repositorySource: source } }), /invalid repository selection/); + }); + } + + test('rejects a revision without a source in session state', () => { + assert.throws(() => getRepositorySessionSource({ schema, values: { repositoryRevision: 'main' } }), /invalid repository selection/); + }); + + test('rejects unadvertised repository state rather than treating it as a directory session', () => { + assert.throws(() => getRepositorySessionSource({ + schema: { type: 'object', properties: {} }, + values: { repositorySource: repository.toString() }, + }), /invalid repository selection/); + }); + function session(lifecycle: SessionLifecycle, withRepository = true): SessionState { return upcastPartial({ lifecycle, workingDirectories: lifecycle === SessionLifecycle.Ready ? ['file:///checkout/repo'] : undefined, - config: withRepository ? { schema, values: { source: repository.toString() } } : undefined, + config: withRepository ? { schema, values: { repositorySource: repository.toString() } } : undefined, }); } @@ -197,13 +295,30 @@ suite('AgentHostRepositoryConfig', () => { }); test('lost-response recovery must match the originally requested repository', async () => { - const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { source: 'https://example.com/another/repo' } } }); + const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { repositorySource: 'https://example.com/another/repo' } } }); await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository), /did not report a ready checkout/); }); test('lost-response recovery must also preserve an explicitly requested revision', async () => { - const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { source: repository.toString(), branch: 'other' } } }); - await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, { branch: 'main' }), /did not report a ready checkout/); + const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { repositorySource: repository.toString(), repositoryRevision: 'other' } } }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, { repositoryRevision: 'main' }), /did not report a ready checkout/); + }); + + test('lost-response recovery does not forget a requested revision when its schema entry disappears', async () => { + const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema: sourceOnlySchema, values: { repositorySource: repository.toString() } } }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, { repositoryRevision: 'main' }), /did not report a ready checkout/); + }); + + test('one repository can resolve to multiple working directories', async () => { + const state = { ...session(SessionLifecycle.Ready), workingDirectories: ['file:///checkout/repo/packages/api', 'file:///checkout/repo/packages/web'] }; + const h = subscription(state); + assert.strictEqual(await waitForRepositorySessionReady(h.sub, CancellationToken.None, repository), state); + }); + + test('advertising repository inputs without selecting a source preserves directory session behavior', async () => { + const state = { ...session(SessionLifecycle.Creating), config: { schema, values: { mode: 'interactive' } } }; + const h = subscription(state); + assert.strictEqual(await waitForRepositorySessionReady(h.sub, CancellationToken.None), state); }); test('keeps the existing lifecycle behavior for non-repository sessions', async () => { From 9c6764a395581406c761ca3b0a91870ff0a9d89d Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 17 Sep 2026 18:38:24 -0700 Subject: [PATCH 4/4] agentHost: Use typed repository source session fields Move repository intent out of provider config and carry typed source/revision fields through creation, queries, session metadata and recovery. Discover support through per-agent capabilities and reject unsupported native-host requests. Prepare source drafts on first send while preserving eager directory creation, trust checks and customization rebinding. Verify exact recovery intent, including an omitted revision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79731f48-d288-483f-8809-136aa141d1eb --- .../browser/agentHostProtocolClient.ts | 8 + src/vs/platform/agentHost/common/agent.ts | 13 +- .../common/agentHostRepositorySource.ts | 55 +++++ .../agentHost/common/sessionConfigKeys.ts | 8 +- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-root/commands.ts | 24 +- .../protocol/channels-root/notifications.ts | 3 +- .../state/protocol/channels-root/state.ts | 11 + .../protocol/channels-session/commands.ts | 26 +- .../state/protocol/channels-session/state.ts | 24 +- .../agentHost/common/state/sessionState.ts | 2 + .../platform/agentHost/node/agentService.ts | 7 + .../agentHost/node/protocolServerHandler.ts | 12 + .../agentHostProtocolClient.test.ts | 91 +++++++ .../agentHost/test/node/agentService.test.ts | 17 ++ .../test/node/protocolServerHandler.test.ts | 52 +++- .../browser/baseAgentHostSessionsProvider.ts | 79 ++++-- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 6 +- .../remoteAgentHostSessionsProvider.test.ts | 105 +++++++- .../browser/sessionsManagementService.ts | 28 ++- .../services/sessions/common/session.ts | 3 + .../sessions/common/sessionsManagement.ts | 3 + .../sessions/common/sessionsProvider.ts | 2 + .../browser/sessionsManagementService.test.ts | 53 ++++ .../agentHost/agentHostRepositoryConfig.ts | 117 ++++----- .../agentHost/agentHostSessionHandler.ts | 94 +++---- .../chat/common/chatService/chatService.ts | 2 + .../common/chatService/chatServiceImpl.ts | 2 + .../chat/common/participants/chatAgents.ts | 2 + .../agentHostChatContribution.test.ts | 43 +++- .../agentHostRepositoryConfig.test.ts | 232 +++++++++--------- 31 files changed, 794 insertions(+), 332 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostRepositorySource.ts diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 6566e7253d0f1a..92bf3f13efd72c 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1339,6 +1339,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect _meta: config?._meta, provider, workingDirectories: config?.workingDirectories?.map(d => fromAgentHostUri(d).toString()), + ...(config?.repositorySource !== undefined ? { repositorySource: config.repositorySource.toString() } : {}), + ...(config?.repositoryRevision !== undefined ? { repositoryRevision: config.repositoryRevision } : {}), config: config?.config, activeClient: config?.activeClient, progressToken: config?.progressToken, @@ -1386,6 +1388,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect channel: ROOT_STATE_URI, provider: params.provider, workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined, + ...(params.repositorySource !== undefined ? { repositorySource: params.repositorySource.toString() } : {}), + ...(params.repositoryRevision !== undefined ? { repositoryRevision: params.repositoryRevision } : {}), config: params.config, }); } @@ -1395,6 +1399,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect channel: ROOT_STATE_URI, provider: params.provider, workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined, + ...(params.repositorySource !== undefined ? { repositorySource: params.repositorySource.toString() } : {}), + ...(params.repositoryRevision !== undefined ? { repositoryRevision: params.repositoryRevision } : {}), config: params.config, property: params.property, query: params.query, @@ -1659,6 +1665,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect activity: s.activity, workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? this._toClientUri(URI.parse(s.workingDirectories[0])) : undefined, workingDirectories: s.workingDirectories?.map(d => this._toClientUri(URI.parse(d))), + ...(s.repositorySource !== undefined ? { repositorySource: URI.parse(s.repositorySource) } : {}), + ...(s.repositoryRevision !== undefined ? { repositoryRevision: s.repositoryRevision } : {}), changes: s.changes, // Carry durable host provenance for sessions first materialized from a listing. ...(s._meta !== undefined ? { _meta: s._meta } : {}), diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index e50afee74b8e95..2c8ad700b21d92 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -170,6 +170,8 @@ export type IAgentKnownSessionsFilter = (sessions: readonly URI[]) => Promise { readonly session: URI; + readonly repositorySource?: URI; + readonly repositoryRevision?: string; } export interface IAgentSessionProjectInfo { @@ -413,6 +415,9 @@ export interface IAgentCreateSessionConfig { * the compatibility phase callers supply exactly one directory (`[dir]`). */ readonly workingDirectories?: readonly URI[]; + /** Requested source identity, separate from the resolved working directories. */ + readonly repositorySource?: URI; + readonly repositoryRevision?: string; readonly config?: Record; /** * Eagerly claim the active client role for the new session. When provided, @@ -829,8 +834,12 @@ export interface IAgentChatConfigCompletionsParams extends IAgentResolveChatConf readonly query?: string; } -export type IAgentResolveSessionConfigParams = IAgentResolveChatConfigParams; -export type IAgentSessionConfigCompletionsParams = IAgentChatConfigCompletionsParams; +export interface IAgentResolveSessionConfigParams extends IAgentResolveChatConfigParams { + readonly repositorySource?: URI; + readonly repositoryRevision?: string; +} + +export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessionConfigParams, IAgentChatConfigCompletionsParams { } /** Serializable model information from the agent host. */ export interface IAgentModelInfo { diff --git a/src/vs/platform/agentHost/common/agentHostRepositorySource.ts b/src/vs/platform/agentHost/common/agentHostRepositorySource.ts new file mode 100644 index 00000000000000..ec186290e46cbf --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostRepositorySource.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { RepositorySourceCapability } from './state/protocol/channels-root/state.js'; +import { JsonRpcErrorCodes } from './state/protocol/errors.js'; +import { ProtocolError } from './state/sessionProtocol.js'; + +export interface IAgentRepositorySource { + readonly repositorySource: URI; + readonly repositoryRevision?: string; +} + +/** Validate typed source inputs without interpreting them as provider configuration. */ +export function validateRepositorySource( + params: { readonly repositorySource?: URI | string; readonly repositoryRevision?: string; readonly config?: Readonly> } | undefined, + capability: RepositorySourceCapability | undefined, +): IAgentRepositorySource | undefined { + const config = params?.config; + if (config && ['repositorySource', 'repositoryRevision', 'repositoryUrl'].some(key => Object.hasOwn(config, key))) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('repositorySource.config', "Repository source and revision must be supplied as request fields, not configuration values.")); + } + if (!params || (params.repositorySource === undefined && params.repositoryRevision === undefined)) { + return undefined; + } + if (!capability) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('repositorySource.unsupported', "The agent host does not support repository-backed session creation.")); + } + if (params.repositorySource === undefined) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('repositorySource.required', "A repository revision requires a repository source.")); + } + let source: URI; + const invalidSource = localize('repositorySource.invalid', "Select an absolute repository URI without credentials, a query, or a fragment."); + try { + source = typeof params.repositorySource === 'string' ? URI.parse(params.repositorySource, true) : params.repositorySource; + } catch { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, invalidSource); + } + if (!URI.isUri(source) || !source.scheme || source.authority.includes('@') || source.query || source.fragment) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, invalidSource); + } + const revision = params.repositoryRevision; + if (revision !== undefined) { + if (capability.revision !== true) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('repositorySource.revisionUnsupported', "The agent host does not support repository revision selection.")); + } + if (typeof revision !== 'string' || !revision.trim()) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('repositorySource.revisionInvalid', "The repository revision must be a nonempty string.")); + } + } + return { repositorySource: source, ...(revision !== undefined ? { repositoryRevision: revision } : {}) }; +} diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index a136e1b77b576a..6d504040be11bc 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -7,8 +7,8 @@ * Well-known keys used in the agent-host configuration value bag. * * The Agent Host Protocol's config schema is intentionally generic — agents - * can advertise provider-specific property names alongside standardized inputs. - * These constants capture the names that the platform itself consumes (e.g. {@link SessionConfigKey.AutoApprove} + * are free to advertise any property names. These constants capture the + * names that the platform itself consumes (e.g. {@link SessionConfigKey.AutoApprove} * drives tool auto-approval) or that clients interpret via convention * (e.g. {@link SessionConfigKey.Branch}, {@link SessionConfigKey.Isolation}). * @@ -27,10 +27,6 @@ export const enum SessionConfigKey { Isolation = 'isolation', /** `'branch'` — host-owned base branch to work from. */ Branch = 'branch', - /** Standard AHP source URI for repository-backed session creation. */ - RepositorySource = 'repositorySource', - /** Standard AHP requested repository revision, separate from the working branch. */ - RepositoryRevision = 'repositoryRevision', /** `'mode'` — agent execution mode (interactive / plan / autopilot). */ Mode = 'mode', /** `'worktreeBranchPrefix'` — host-owned prefix for the worktree branch name. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index fa2ba84b23a7a1..dd4d0d407a76f0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -b6a62eba +fa44ef3f diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts index c82e678c096314..1bfc232b949255 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts @@ -79,12 +79,8 @@ export interface ListSessionsResult extends PaginatedResult { * the full current property set (not a delta). The returned `values` contain * server-resolved defaults to pass to `createSession`. * - * Repository-backed creation is advertised by a valid - * `schema.properties.repositorySource`, with optional - * `schema.properties.repositoryRevision`; see {@link SessionConfigSchema}. - * Values use those fixed keys in `config`. Resolving the schema or its values, - * including discovery without a working directory, MUST NOT clone or prepare - * a repository; preparation belongs to `createSession`. + * This command MUST NOT clone or prepare a repository. Repository context + * requires the agent's `repositorySource` capability. * * @category Commands * @method resolveSessionConfig @@ -137,13 +133,11 @@ export interface ResolveSessionConfigParams extends BaseParams { provider?: string; /** Working directory for the session */ workingDirectory?: URI; - /** - * Current user-filled configuration values. Repository intent uses - * `repositorySource` and optional `repositoryRevision` only when advertised - * by the session config schema. Invalid or unsupported repository input MUST - * produce `InvalidParams` (`-32602`), not silently select directory/default - * behavior. - */ + /** Credential-free source context; not a working-directory URI. */ + repositorySource?: URI; + /** Requested revision; requires a source and the capability's revision option. */ + repositoryRevision?: string; + /** Current user-filled configuration values; see {@link SessionConfigSchema}. */ config?: Record; } @@ -208,6 +202,10 @@ export interface SessionConfigCompletionsParams extends BaseParams { provider?: string; /** Working directory for the session */ workingDirectory?: URI; + /** Repository context for configuration completions; this MUST NOT prepare a checkout. */ + repositorySource?: URI; + /** Requested revision; requires a source and the capability's revision option. */ + repositoryRevision?: string; /** Current user-filled configuration values (provides context for the query) */ config?: Record; /** Property id from the schema to query values for */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 6a24571e13f0b1..e3b6a34b5fac19 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -177,8 +177,7 @@ export interface SessionSummaryChangedParams { * reconnect. A client that never receives the terminal frame SHOULD expire * the indicator after an idle timeout. * - Completion of reported work does not establish session readiness. - * Repository-backed creation uses session state and the existing - * `session/ready` or `session/creationFailed` actions for its durable outcome. + * Observe session lifecycle state for the durable outcome. * * @category Protocol Notifications * @method root/progress diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 51ce7c2508dd8f..431790b5d51829 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -102,6 +102,8 @@ export interface AgentInfo { * @category Root State */ export interface AgentCapabilities { + /** The host accepts typed repository inputs for session creation and configuration queries. */ + repositorySource?: RepositorySourceCapability; /** * The agent can host more than one concurrent chat per session. When absent, * clients MUST NOT call `createChat` to open chats beyond the default one the @@ -123,6 +125,15 @@ export interface AgentCapabilities { multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; } +/** + * Options for repository-backed session creation. + * @category Root State + */ +export interface RepositorySourceCapability { + /** When true, clients may supply an explicit repositoryRevision. */ + revision?: boolean; +} + /** * Options for the {@link AgentCapabilities.multipleChats} capability. * diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index bffa6a5c0dc26b..22f89c6ce5b957 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -23,14 +23,8 @@ import type { MessageAttachment } from '../channels-chat/state.js'; * updates. The server also broadcasts a `root/sessionAdded` notification to all * clients. * - * For repository intent advertised by {@link SessionConfigSchema.properties}, - * the host MUST authorize the request before repository side effects and - * prepare the repository before executing turns. It MUST publish the requested - * `repositorySource` and optional `repositoryRevision` in - * {@link SessionState.config} from the initial `creating` snapshot and retain - * them through `ready` or `failed`. Any resolved `workingDirectories` MUST be - * published before `session/ready` or `session/creationFailed`. Clients recover - * the outcome from session state, not progress notifications. + * Repository preparation MUST finish before `session/ready` or executing turns. + * Clients recover the outcome from session state, not progress notifications. * * @category Commands * @method createSession @@ -73,22 +67,18 @@ export interface CreateSessionParams extends BaseParams { * and ignores the rest. Dispatch working-directory actions to change the set * after the session has started. * - * A non-empty list and repository intent in `config` are mutually exclusive. + * A non-empty list and `repositorySource` are mutually exclusive. * A repository URI identifies the source, not a working-directory URI; one * source may produce multiple directories. */ workingDirectories?: URI[]; + /** Credential-free source to prepare; requires the agent's repositorySource capability. */ + repositorySource?: URI; + /** Requested branch, tag, or commit; requires a source and the capability's revision option. */ + repositoryRevision?: string; /** * Session configuration values collected via `resolveSessionConfig`. - * Keys and values correspond to the schema returned by the server. - * Repository intent uses the standard `repositorySource` and optional - * `repositoryRevision` keys only when advertised by - * {@link SessionConfigSchema.properties}. Values MUST be non-empty strings; - * the source MUST be a credential-free repository URI. A revision without a - * source, unsupported input, or conflicting directories MUST produce - * `InvalidParams` (`-32602`), not silently fall back. Omitting repository - * intent preserves existing directory/default behavior. Other keys remain - * host-defined. + * Keys and values follow the advertised {@link SessionConfigSchema}. */ config?: Record; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 43fb11008c4dd9..14e8f4ade28ddf 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -126,6 +126,10 @@ export interface SessionMetadata { * chat that sets none operates against this full set. */ workingDirectories?: URI[]; + /** Immutable requested source, separate from the host-resolved working directories. */ + repositorySource?: URI; + /** Immutable requested revision, not the checkout's current HEAD. */ + repositoryRevision?: string; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render @@ -174,12 +178,7 @@ export interface SessionState extends SessionMetadata { * this over the session's lifetime. */ defaultChat?: URI; - /** - * Session configuration schema and current values. For repository-backed - * creation, this includes the advertised standard properties and requested - * `repositorySource` and optional `repositoryRevision` values throughout - * `creating`, `ready`, and `failed`, so clients can recover intent from state. - */ + /** Provider-specific session configuration schema and current values. */ config?: SessionConfigState; /** * Top-level customizations active in this session. @@ -563,19 +562,6 @@ export interface SessionConfigPropertySchema extends ConfigPropertySchema { /** * A JSON Schema object describing available session configuration metadata. * - * Repository-backed creation uses the standard optional config keys - * `repositorySource` (a credential-free repository URI) and - * `repositoryRevision` (a branch, tag, or commit). Support is advertised by - * `properties.repositorySource`; `properties.repositoryRevision` MUST NOT be - * advertised without it. Each advertised property MUST have `type: 'string'` - * and MUST NOT have `readOnly: true` or `sessionMutable: true`. - * - * The host MUST NOT accept repository inputs unless their corresponding - * properties are advertised. Values travel through `resolveSessionConfig.config` - * and `createSession.config`; schema discovery MUST NOT prepare a repository. - * Neither key is globally required. Without repository intent, existing - * directory/default behavior is unchanged. Other property ids remain host-defined. - * * @category Session Config Types */ export interface SessionConfigSchema { diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 33768d01d4b446..e9790e727f01ac 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -955,6 +955,8 @@ export function createSessionState(summary: SessionSummary): SessionState { if (summary.activity !== undefined) { state.activity = summary.activity; } if (summary.project !== undefined) { state.project = summary.project; } if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; } + if (summary.repositorySource !== undefined) { state.repositorySource = summary.repositorySource; } + if (summary.repositoryRevision !== undefined) { state.repositoryRevision = summary.repositoryRevision; } if (summary.annotations !== undefined) { state.annotations = summary.annotations; } if (summary._meta !== undefined) { state._meta = summary._meta; } return state; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 408ba064e7dffb..e9eb8039b68683 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -20,6 +20,7 @@ import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileO import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; +import { validateRepositorySource } from '../common/agentHostRepositorySource.js'; import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDatabase, ISessionDataService, ISessionStorageAccessCounts, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; @@ -3720,11 +3721,15 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: new Date(meta.modifiedTime).toISOString(), ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), workingDirectories: meta.workingDirectories?.map(d => d.toString()), + ...(meta.repositorySource !== undefined ? { repositorySource: meta.repositorySource.toString() } : {}), + ...(meta.repositoryRevision !== undefined ? { repositoryRevision: meta.repositoryRevision } : {}), _meta: meta._meta, }; } async createSession(config?: IAgentCreateSessionConfig): Promise { + // This host does not advertise repository preparation. + validateRepositorySource(config, undefined); const provider = this._providerService.resolveProvider(config?.provider); const isEphemeral = config ? readEphemeralSessionMeta(config).isEphemeral === true : false; if (!provider) { @@ -5002,6 +5007,7 @@ export class AgentService extends Disposable implements IAgentService { } async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + validateRepositorySource(params, undefined); const provider = this._providerService.resolveProvider(params.provider); if (!provider) { throw new Error(`No agent provider registered for: ${params.provider ?? '(none)'}`); @@ -5076,6 +5082,7 @@ export class AgentService extends Disposable implements IAgentService { } async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + validateRepositorySource(params, undefined); // The host owns branch completions for every agent (they share the same // git-backed branch list); all other properties stay provider-specific. if (params.property === SessionConfigKey.Branch && this._worktree.supported) { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index f575aa78411ea9..a4e6d90f670c08 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -18,6 +18,7 @@ import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js' import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, readClientTelemetryLevel, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; +import { validateRepositorySource } from '../common/agentHostRepositorySource.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { isAnnotationsUri } from '../common/annotationsUri.js'; import { type IAgentService } from '../common/agentService.js'; @@ -1555,6 +1556,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } }, createSession: async (_client, params) => { + validateRepositorySource(params, undefined); let createdSession: URI; // If the client eagerly claimed the active client role, validate // the clientId matches the connection before forwarding. @@ -1566,6 +1568,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien provider: params.provider, _meta: params._meta, workingDirectories: params.workingDirectories?.map(d => URI.parse(d)), + ...(params.repositorySource !== undefined ? { repositorySource: URI.parse(params.repositorySource) } : {}), + ...(params.repositoryRevision !== undefined ? { repositoryRevision: params.repositoryRevision } : {}), session: URI.parse(params.channel), config: params.config, activeClient: params.activeClient, @@ -1654,6 +1658,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien modifiedAt: new Date(s.modifiedTime).toISOString(), ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), workingDirectories: s.workingDirectories?.map(d => d.toString()), + ...(s.repositorySource !== undefined ? { repositorySource: s.repositorySource.toString() } : {}), + ...(s.repositoryRevision !== undefined ? { repositoryRevision: s.repositoryRevision } : {}), changes: s.changes, // `_meta` carries durable host provenance, including session kind // and provider-native discovery provenance. @@ -1672,16 +1678,22 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return this._agentService.fetchAutomationRuns(params); }, resolveSessionConfig: async (_client, params) => { + validateRepositorySource(params, undefined); return this._agentService.resolveSessionConfig({ provider: params.provider, workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, + ...(params.repositorySource !== undefined ? { repositorySource: URI.parse(params.repositorySource) } : {}), + ...(params.repositoryRevision !== undefined ? { repositoryRevision: params.repositoryRevision } : {}), config: params.config, }); }, sessionConfigCompletions: async (_client, params) => { + validateRepositorySource(params, undefined); return this._agentService.sessionConfigCompletions({ provider: params.provider, workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, + ...(params.repositorySource !== undefined ? { repositorySource: URI.parse(params.repositorySource) } : {}), + ...(params.repositoryRevision !== undefined ? { repositoryRevision: params.repositoryRevision } : {}), config: params.config, property: params.property, query: params.query, diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index ffdd813ae47fcc..a863168fa64e40 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -657,6 +657,39 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(sessions.map(s => readSessionExternal(s._meta)), [true]); }); + test('listSessions preserves repository source identity separately from mapped directories', async () => { + const { client, transport } = createClient(); + const resultPromise = client.listSessions(); + const sent = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: sent.id, + result: { + items: [{ + resource: 'ahp-session:/repository', + provider: 'copilot', + title: 'Repository', + status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + repositorySource: 'file:///sources/project', + repositoryRevision: 'main', + workingDirectories: ['file:///worktrees/project'], + }], + }, + }); + const sessions = await resultPromise; + assert.deepStrictEqual(sessions.map(session => ({ + source: session.repositorySource?.toString(), + revision: session.repositoryRevision, + directories: session.workingDirectories, + })), [{ + source: 'file:///sources/project', + revision: 'main', + directories: [toAgentHostUri(URI.file('/worktrees/project'), agentHostAuthority('test.example:1234'))], + }]); + }); + test('listSessions preserves client-addressed remote working directories across reload', async () => { const { client, transport } = createClient(); const remoteDirectory = URI.parse('vscode-remote://ssh-remote+host/workspace'); @@ -855,6 +888,64 @@ suite('AgentHostProtocolClient', () => { assert.strictEqual(await creation, session); }); + for (const repositorySource of [URI.parse('https://git.example.org:8443/team/app.git'), URI.file('/sources/project')]) { + test(`createSession sends repository source as a typed field (${repositorySource.scheme})`, async () => { + const { client, transport } = createClient(); + const session = URI.parse('ahp-session:/source-test'); + const creation = client.createSession({ + provider: 'copilot', + session, + repositorySource, + repositoryRevision: 'refs/tags/v1', + config: { mode: 'plan' }, + }); + const request = transport.sentMessages[0] as JsonRpcRequest; + assert.deepStrictEqual(request.params, { + channel: session.toString(), + provider: 'copilot', + _meta: undefined, + workingDirectories: undefined, + repositorySource: repositorySource.toString(), + repositoryRevision: 'refs/tags/v1', + config: { mode: 'plan' }, + activeClient: undefined, + progressToken: undefined, + }); + transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: null }); + await creation; + }); + } + + for (const method of ['resolveSessionConfig', 'sessionConfigCompletions'] as const) { + test(`${method} sends typed repository context outside config`, async () => { + const { client, transport } = createClient(); + const context = { + provider: 'copilot', + repositorySource: URI.parse('https://git.example.org:8443/team/app.git'), + repositoryRevision: 'main', + config: { target: 'worktree' }, + }; + const resultPromise = method === 'resolveSessionConfig' + ? client.resolveSessionConfig(context) + : client.sessionConfigCompletions({ ...context, property: 'branch', query: 'feature' }); + const request = transport.sentMessages[0] as JsonRpcRequest; + assert.deepStrictEqual({ method: request.method, params: request.params }, { + method, + params: { + channel: ROOT_STATE_URI, + provider: 'copilot', + workingDirectory: undefined, + repositorySource: context.repositorySource.toString(), + repositoryRevision: 'main', + config: { target: 'worktree' }, + ...(method === 'sessionConfigCompletions' ? { property: 'branch', query: 'feature' } : {}), + }, + }); + transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: method === 'resolveSessionConfig' ? { schema: { type: 'object', properties: {} }, values: context.config } : { items: [] } }); + await resultPromise; + }); + } + suite('createChat', () => { const sessionUri = URI.parse('ahp-session:/test'); const chatUri = URI.parse('ahp-session:/test/chat-1'); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b6efab7672cfcf..3ecfd2f17d230f 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3919,6 +3919,23 @@ suite('AgentService (node dispatcher)', () => { suite('createSession', () => { + test('native host rejects repository source inputs without provisioning', async () => { + registerTestAgentProvider(service, copilotAgent); + const repositorySource = URI.parse('https://example.com/team/project'); + await assert.rejects(service.createSession({ provider: 'copilot', repositorySource }), /does not support repository-backed/); + await assert.rejects(service.resolveSessionConfig({ provider: 'copilot', repositorySource }), /does not support repository-backed/); + await assert.rejects(service.sessionConfigCompletions({ provider: 'copilot', repositorySource, property: 'branch' }), /does not support repository-backed/); + assert.deepStrictEqual(await service.listSessions(), []); + }); + + test('native host rejects repository source config aliases instead of silently choosing a directory', async () => { + registerTestAgentProvider(service, copilotAgent); + for (const property of ['repositorySource', 'repositoryRevision', 'repositoryUrl']) { + await assert.rejects(service.createSession({ provider: 'copilot', config: { [property]: null } }), /request fields, not configuration/); + } + assert.deepStrictEqual(await service.listSessions(), []); + }); + test('creates session via specified provider', async () => { registerTestAgentProvider(service, copilotAgent); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 53d4fd114aabad..0bd59a7522366c 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -24,7 +24,7 @@ import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomati import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type ProgressParams, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; -import { AUTOMATION_CATALOG_URI, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams, SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -153,6 +153,8 @@ class MockAgentService implements IAgentService { readonly readErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; + readonly resolveSessionConfigCalls: IAgentResolveSessionConfigParams[] = []; + readonly sessionConfigCompletionsCalls: IAgentSessionConfigCompletionsParams[] = []; managedSettingsDiagnostics: readonly IAgentHostManagedSettingsDiagnostics[] = []; readonly getSessionStateFileCalls: { session: string; chat: string | undefined }[] = []; readonly removeSessionArtifactCalls: { session: string; artifactId: string }[] = []; @@ -210,8 +212,14 @@ class MockAgentService implements IAgentService { return session; } - async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + this.resolveSessionConfigCalls.push(params); + return { schema: { type: 'object', properties: {} }, values: {} }; + } + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + this.sessionConfigCompletionsCalls.push(params); + return { items: [] }; + } async completions(_params: CompletionsParams): Promise { return { items: [] }; } automationCapabilities: AutomationCapabilities | undefined; async listAutomationTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { return { items: [] }; } @@ -1909,6 +1917,44 @@ suite('ProtocolServerHandler', () => { }); }); + for (const method of ['createSession', 'resolveSessionConfig', 'sessionConfigCompletions'] as const) { + test(`${method} rejects unsupported repository source inputs before calling the native host`, async () => { + const transport = connectClient('repository-source-client'); + const inputs = [ + { repositorySource: 'https://example.com/team/project' }, + { repositoryRevision: 'main' }, + { repositorySource: null }, + { config: { repositorySource: 'https://example.com/team/project' } }, + { config: { repositoryRevision: null } }, + { config: { repositoryUrl: 'https://example.com/team/project' } }, + ]; + const errors = []; + for (const [index, input] of inputs.entries()) { + const id = index + 2; + const responsePromise = waitForResponse(transport, id); + transport.simulateMessage(request(id, method, { + channel: method === 'createSession' ? 'copilot:/repository-source' : ROOT_STATE_URI, + provider: 'copilot', + ...(method === 'sessionConfigCompletions' ? { property: 'branch' } : {}), + ...input, + })); + const response = await responsePromise; + errors.push(isJsonRpcResponse(response) && hasKey(response, { error: true }) ? response.error?.code : undefined); + } + assert.deepStrictEqual({ + errors, + creates: agentService.createSessionConfigs.length, + resolves: agentService.resolveSessionConfigCalls.length, + completions: agentService.sessionConfigCompletionsCalls.length, + }, { + errors: inputs.map(() => JsonRpcErrorCodes.InvalidParams), + creates: 0, + resolves: 0, + completions: 0, + }); + }); + } + test('whenIdle waits for in-flight protocol requests after disposal', async () => { const transport = connectClient('client-drain'); agentService.createSessionBarrier = new DeferredPromise(); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 9e865ed49ecf5c..edf36e4a273727 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -21,6 +21,7 @@ import { localize } from '../../../../../nls.js'; import { AgentSession, AuthenticateParams, AuthenticateResult, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn } from '../../../../../platform/agentHost/common/agent.js'; import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { validateRepositorySource } from '../../../../../platform/agentHost/common/agentHostRepositorySource.js'; import type { AgentHostUriMapper } from '../../../../../platform/agentHost/common/agentHostUri.js'; import type { RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { AgentHostTransportFailureReason } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; @@ -47,6 +48,7 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { AgentHostDownloadProgress } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.js'; +import { getRepositorySourceCapability, getRepositorySourceFromSelection } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.js'; import { IAgentCustomizationScope, IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { ChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; @@ -1922,6 +1924,8 @@ function flattenActiveClientCustomizations(state: SessionState): ClientPluginCus * Inputs needed to construct a {@link NewSession}. */ interface INewSessionConstructionContext { + readonly repositorySource?: URI; + readonly repositoryRevision?: string; /** * Workspace the session is scoped to, or `undefined` for a **quick chat** * (a workspace-less session not bound to any folder). When `undefined`, @@ -2015,6 +2019,8 @@ class NewSession extends Disposable { /** This draft's URI as the host's registry is keyed by it. */ readonly backendUri: URI; readonly workspaceUri: URI | undefined; + readonly repositorySource: URI | undefined; + readonly repositoryRevision: string | undefined; readonly requiresWorkspaceTrust: boolean; /** `true` when this is a workspace-less quick chat. */ readonly isQuickChat: boolean; @@ -2130,6 +2136,8 @@ class NewSession extends Disposable { throw new Error('Workspace has no repository URI'); } this.workspaceUri = workspaceUri; + this.repositorySource = ctx.repositorySource; + this.repositoryRevision = ctx.repositoryRevision; this.isQuickChat = this._kind.isQuickChat; this.requiresWorkspaceTrust = !!ctx.workspace?.requiresWorkspaceTrust; this.agentProvider = ctx.sessionType.id; @@ -2446,9 +2454,16 @@ class NewSession extends Disposable { const values = this._config?.values ?? this._unresolvedConfigValues; this._isResolvingConfig.set(true, undefined); try { + validateRepositorySource({ + repositorySource: this.repositorySource, + repositoryRevision: this.repositoryRevision, + config: values, + }, this.repositorySource || this.repositoryRevision !== undefined ? getRepositorySourceCapability(connection, this.agentProvider) : undefined); const result = await connection.resolveSessionConfig({ provider: this.agentProvider, - workingDirectory: this.workspaceUri, + workingDirectory: this.repositorySource ? undefined : this.workspaceUri, + ...(this.repositorySource !== undefined ? { repositorySource: this.repositorySource } : {}), + ...(this.repositoryRevision !== undefined ? { repositoryRevision: this.repositoryRevision } : {}), config: values, }); if (seq !== this._configRequestSeq) { @@ -2462,6 +2477,9 @@ class NewSession extends Disposable { if (seq !== this._configRequestSeq) { return false; } + if (this.repositorySource) { + this._logService.warn('Failed to resolve repository session configuration', error); + } this._config = undefined; this._unresolvedConfigValues = values; this._syncWorktreePending(); @@ -2480,7 +2498,9 @@ class NewSession extends Disposable { getConfigCompletions(connection: IAgentConnection, property: string, query: string | undefined) { return connection.sessionConfigCompletions({ provider: this.agentProvider, - workingDirectory: this.workspaceUri, + workingDirectory: this.repositorySource ? undefined : this.workspaceUri, + ...(this.repositorySource !== undefined ? { repositorySource: this.repositorySource } : {}), + ...(this.repositoryRevision !== undefined ? { repositoryRevision: this.repositoryRevision } : {}), config: this._config?.values, property, query, @@ -2540,6 +2560,11 @@ class NewSession extends Disposable { let createdWithActiveClient: SessionActiveClient | undefined; try { + validateRepositorySource({ + repositorySource: this.repositorySource, + repositoryRevision: this.repositoryRevision, + config: this._config?.values, + }, this.repositorySource || this.repositoryRevision !== undefined ? getRepositorySourceCapability(connection, this.agentProvider) : undefined); await this._activeClientScope.whenResolved(); if (this._backendUri?.toString() !== backendUri.toString()) { return; @@ -2550,7 +2575,9 @@ class NewSession extends Disposable { provider: this.agentProvider, session: backendUri, ...(this._initialSessionTemplate?.modelId ? { model: this.getSelectedModel() } : {}), - workingDirectories: this.workspaceUri ? [this.workspaceUri] : undefined, + workingDirectories: !this.repositorySource && this.workspaceUri ? [this.workspaceUri] : undefined, + ...(this.repositorySource !== undefined ? { repositorySource: this.repositorySource } : {}), + ...(this.repositoryRevision !== undefined ? { repositoryRevision: this.repositoryRevision } : {}), config: this._config?.values, _meta: this._initialMetadata, // MCP-style opt-in: offer to receive `progress` for any @@ -3219,11 +3246,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeCustomizations.fire(); } - /** - * Reconcile {@link _sessionTypes} against the agents advertised by the - * host's root state, firing {@link onDidChangeSessionTypes} only if the - * id/label set actually changed. - */ + /** Reconcile session types and creation capabilities with the host's root state. */ protected _syncSessionTypesFromRootState(rootState: RootState): void { this._syncAgentCapabilities(rootState.agents); const next = rootState.agents @@ -3231,6 +3254,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement .map((agent): ISessionType => ({ id: agent.provider, supportsWorktreeConfiguration: agent.provider === CopilotCLISessionType.id, + ...(agent.capabilities?.repositorySource ? { + supportsRepositorySource: true, + supportsRepositoryRevision: agent.capabilities.repositorySource.revision === true, + } : {}), authRequirement: resolveAgentAuthRequirement(agent), // The chat session contribution and language models for an agent-host // agent are registered under its resource scheme (`agent-host-`), @@ -3241,7 +3268,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement })); const prev = this._sessionTypes; - if (prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label && t.authRequirement === next[i].authRequirement)) { + if (prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label && t.authRequirement === next[i].authRequirement + && t.supportsRepositorySource === next[i].supportsRepositorySource && t.supportsRepositoryRevision === next[i].supportsRepositoryRevision)) { return; } this._sessionTypes = next; @@ -3566,8 +3594,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement sessionType, workspace, false, - options?.metadata, - options?.automationConfiguration, + options, ); } @@ -3580,6 +3607,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } createQuickChat(sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { + if (options?.repositorySource !== undefined || options?.repositoryRevision !== undefined) { + throw new Error(localize('agentHost.repositoryQuickChat', "Repository inputs require a workspace-bound session, not a quick chat.")); + } const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId); if (!sessionType) { throw new Error(this._noAgentsErrorMessage()); @@ -3595,8 +3625,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement sessionType, undefined, true, - options?.metadata, - options?.automationConfiguration, + options, ); } @@ -3605,26 +3634,39 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * given session type. Shared by {@link createNewSession} (workspace-bound) * and {@link createQuickChat} (workspace-less, `quickChat === true`). */ - private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record, initialAutomationConfiguration?: IAutomationSessionConfiguration): ISession { + private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, options?: ISessionsProviderCreateSessionOptions): ISession { // Tear-down of superseded drafts is handled by the management layer // (it calls `deleteNewSession` on the previous pending session). Each // new session is tracked independently in `_newSessions` so several can // be in flight at once (e.g. one sending in the background while the // composer re-seeds a fresh draft). const connection = this.connection; + const initialMetadata = options?.metadata; + const initialAutomationConfiguration = options?.automationConfiguration; + const repositorySource = options?.repositorySource + ?? (connection ? getRepositorySourceFromSelection(connection, sessionType.id, workspace?.folders[0]?.root) : undefined); const resourceScheme = this.resourceSchemeForProvider(sessionType.id); const initialSessionTemplate = this._resolveAutomationSessionTemplate(sessionType.id, initialAutomationConfiguration); - const activeClientScope = this._activeClientService.acquireScope(resourceScheme, workspace?.folders.map(folder => folder.root) ?? []); const initialConfigValues = initialAutomationConfiguration ? { ...this._derivedNewSessionConfig(workspace), ...this._normalizeAutomationSessionConfig(initialSessionTemplate?.config), } : this._initialNewSessionConfig(workspace); + if (connection) { + validateRepositorySource({ + repositorySource, + repositoryRevision: options?.repositoryRevision, + config: initialConfigValues, + }, repositorySource || options?.repositoryRevision !== undefined ? getRepositorySourceCapability(connection, sessionType.id) : undefined); + } + const activeClientScope = this._activeClientService.acquireScope(resourceScheme, repositorySource ? [] : workspace?.folders.map(folder => folder.root) ?? []); let newSession: NewSession; try { newSession = this._instantiationService.createInstance(NewSession, { workspace, + repositorySource, + repositoryRevision: options?.repositoryRevision, quickChat, sessionType, providerId: this.id, @@ -3729,6 +3771,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // is part of viewing the new-session UI and stays ungated. void newSession.trackConfigResolution(this._refreshNewSessionConfig(newSession, { markSessionLoading: true })); + // Prepare repository drafts on first send, after the user finishes choosing configuration. + if (newSession.repositorySource) { + return; + } + // Defense-in-depth: never eagerly spawn an agent backend in an // untrusted folder. The interactive trust prompt lives at folder-pick // time (newChatWidget) and a backstop runs on first Send @@ -5215,6 +5262,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement agentIdSilent: contribution?.type, attachedContext, agentHostSessionConfig: this.getCreateSessionConfig(chatId), + ...(newSession.repositorySource !== undefined ? { agentHostRepositorySource: newSession.repositorySource } : {}), + ...(newSession.repositoryRevision !== undefined ? { agentHostRepositoryRevision: newSession.repositoryRevision } : {}), hideFromTranscript: options.hideFromTranscript, metadata: options.metadata, }; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index dc9aab15b97a77..8b980bab2d2558 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -63,11 +63,11 @@ Remote session and chat resources preserve connection-specific routing identity ### Repository-backed session creation -A repository selection is intent, not a host filesystem directory. When the host cannot address the selected repository URI as a directory, the client discovers its session configuration. The host advertises the standard `repositorySource` input, and optionally `repositoryRevision`, as creation-writable string properties in its configuration schema. The client passes the source URI and requested revision under those fixed keys in ordinary session creation without invoking a vendor cloning method. Hosts that do not advertise the source input retain the existing directory-selection behavior; explicitly supplied repository inputs must not be silently dropped. +A repository selection is intent, not a host filesystem directory. Agents advertise source-based creation through `capabilities.repositorySource`, with `revision: true` when revision selection is supported. The client passes `repositorySource` and optional `repositoryRevision` as typed fields on session creation and configuration queries, separate from `config` and working directories. Hosts without the capability retain the existing directory-selection behavior; explicitly supplied unsupported source inputs fail instead of silently falling back. -The host owns checkout preparation and publishes its outcome through session state. Requested source and revision remain in configuration values, separate from the resulting working directories. A source can have multiple working directories or different worktrees across sessions. A repository-backed session must reach `ready` with its selected repository and resolved directories before the client sends a turn. The client rebinds workspace-scoped customizations to those directories, propagates creation failures and allows a cancelled local wait to stop without disposing shared host resources. Reconnection observes the existing session; a lost creation reply must not cause an unrelated session to be accepted under the same URI. +The host owns checkout preparation and publishes its outcome through session state. Repository drafts resolve configuration without eagerly preparing a checkout; first send starts creation through the shared handler. Directory-backed drafts retain their existing eager behavior. Requested source and revision are immutable typed session metadata, separate from the resulting working directories. A source can have multiple working directories or different worktrees across sessions. A repository-backed session must reach `ready` with matching source/revision and resolved directories before the client sends a turn. The client rebinds workspace-scoped customizations to those directories, checks trust on resolved local roots, propagates creation failures and allows a cancelled local wait to stop without disposing shared host resources. Reconnection observes the existing session; a lost creation reply must not cause an unrelated session to be accepted under the same URI. -This is an optional protocol capability, not a requirement that every host use Git or materialize a local directory. Directory-based requests, existing sessions and hosts without repository configuration keep their existing behavior. +This is an optional protocol capability, not a requirement that every host use Git or materialize a local directory. Directory-based requests and ordinary provider configuration keep their existing behavior. ## Authentication and recovery diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 169fb1cea3b672..24906204d04611 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -13,7 +13,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { AgentSession, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { IAgentHostConnectionsService, type IAgentHostSessionResolutionPolicy, type IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; @@ -77,6 +77,8 @@ class MockAgentConnection extends mock() { public dispatchedActions: { channel: string; action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; + readonly createdSessionConfigs: IAgentCreateSessionConfig[] = []; + readonly resolveSessionConfigCalls: IAgentResolveSessionConfigParams[] = []; private _nextSeq = 0; @@ -107,13 +109,17 @@ class MockAgentConnection extends mock() { } public createdSessionUris: URI[] = []; - override async createSession(config?: { session?: URI }): Promise { + override async createSession(config?: IAgentCreateSessionConfig): Promise { + if (config) { + this.createdSessionConfigs.push(config); + } const uri = config?.session ?? URI.parse('copilotcli:///auto'); this.createdSessionUris.push(uri); return uri; } - override async resolveSessionConfig(): Promise { + override async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + this.resolveSessionConfigCalls.push(params); await Promise.resolve(); if (this.failResolveSessionConfig) { throw new Error('resolveSessionConfig unavailable'); @@ -434,6 +440,36 @@ suite('RemoteAgentHostSessionsProvider', () => { ]); }); + test('session types track repository source capability changes', () => { + const provider = createProvider(disposables, connection); + let changes = 0; + disposables.add(provider.onDidChangeSessionTypes!(() => changes++)); + const read = () => ({ + source: provider.sessionTypes[0].supportsRepositorySource, + revision: provider.sessionTypes[0].supportsRepositoryRevision, + }); + const snapshots = [read()]; + for (const capability of [{}, { revision: true }, undefined]) { + connection.setAgents([{ + provider: 'copilotcli', + displayName: 'Copilot', + description: '', + models: [], + capabilities: { repositorySource: capability }, + }]); + snapshots.push(read()); + } + assert.deepStrictEqual({ snapshots, changes }, { + snapshots: [ + { source: undefined, revision: undefined }, + { source: true, revision: false }, + { source: true, revision: true }, + { source: undefined, revision: undefined }, + ], + changes: 3, + }); + }); + test('session-type labels omit host suffix on web', () => { const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host', isWebPlatform: true }); @@ -645,6 +681,36 @@ suite('RemoteAgentHostSessionsProvider', () => { ); }); + test('createNewSession resolves typed repository inputs without eagerly preparing a checkout', async () => { + connection.setAgents([{ + provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], + capabilities: { repositorySource: { revision: true } }, + }]); + const provider = createProvider(disposables, connection); + const repositorySource = URI.parse('https://git.example.org:8443/team/app.git'); + const draft = provider.createNewSession( + URI.parse('vscode-agent-host://localhost__4321/workspace'), + provider.sessionTypes[0].id, + { repositorySource, repositoryRevision: 'main' }, + ); + provider.setAuthenticationPending(false); + await waitForSessionConfig(provider, draft.sessionId, config => config?.values.isolation === 'worktree'); + await timeout(0); + const resolution = connection.resolveSessionConfigCalls.map(call => ({ + source: call.repositorySource?.toString(), revision: call.repositoryRevision, directory: call.workingDirectory, + })); + assert.ok(resolution.length > 0); + assert.deepStrictEqual({ + resolution, + creation: connection.createdSessionConfigs.map(call => ({ + source: call.repositorySource?.toString(), revision: call.repositoryRevision, directories: call.workingDirectories, config: call.config, + })), + }, { + resolution: resolution.map(() => ({ source: repositorySource.toString(), revision: 'main', directory: undefined })), + creation: [], + }); + }); + // ---- Browse actions ------- test('has one browse action for remote folders', () => { @@ -1393,6 +1459,39 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]); }); + test('sendRequest carries typed repository source inputs separately from provider config', async () => { + connection.setAgents([{ + provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], + capabilities: { repositorySource: { revision: true } }, + }]); + const sendOptions: IChatSendRequestOptions[] = []; + const provider = createProvider(disposables, connection, { + openSession: true, + sendRequest: async (_resource, _message, options): Promise => { + if (options) { + sendOptions.push(options); + } + connection.addSession(createSession('source-created-from-send', { summary: 'Repository From Send' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + const repositorySource = URI.parse('https://git.example.org/team/project'); + const session = provider.createNewSession( + URI.parse('vscode-agent-host://localhost__4321/workspace'), + provider.sessionTypes[0].id, + { repositorySource, repositoryRevision: 'main' }, + ); + provider.setAuthenticationPending(false); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); + const chat = await provider.createNewChat(session.sessionId); + await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + assert.deepStrictEqual(sendOptions.map(options => ({ + source: options.agentHostRepositorySource?.toString(), + revision: options.agentHostRepositoryRevision, + config: options.agentHostSessionConfig, + })), [{ source: repositorySource.toString(), revision: 'main', config: { isolation: 'worktree' } }]); + }); + // ---- Session data adapter ------- test('session adapter has correct workspace from working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index b4f3f375fd6ba0..884aedf9086a9d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -424,6 +424,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa * callers can enforce provider-specific trust without resolving it again. */ private _resolveProviderForNewSession(folderUri: URI, options?: ICreateNewSessionOptions): { provider: ISessionsProvider; sessionTypeId: string; workspace: ISessionWorkspace } { + if (options?.repositoryRevision !== undefined && options.repositorySource === undefined) { + throw new Error(localize('sessions.repositorySourceRequired', "A repository revision requires a repository source.")); + } const providers = this.sessionsProvidersService.getProviders(); let provider: ISessionsProvider | undefined; let workspace: ISessionWorkspace | undefined; @@ -432,17 +435,17 @@ export class SessionsManagementService extends Disposable implements ISessionsMa || options?.worktreeBranchTrack !== undefined || options?.worktreeCreateNewBranch !== undefined || options?.branch !== undefined; + const requiresRepositorySource = options?.repositorySource !== undefined || options?.repositoryRevision !== undefined; const resolveSessionTypeId = (candidate: ISessionsProvider): string | undefined => { - const sessionTypes = candidate.getSessionTypes(folderUri); + const sessionTypes = candidate.getSessionTypes(folderUri).filter(type => + (!requiresWorktreeConfiguration || type.supportsWorktreeConfiguration === true) + && (!requiresRepositorySource || type.supportsRepositorySource === true) + && (options?.repositoryRevision === undefined || type.supportsRepositoryRevision === true)); if (options?.sessionTypeId) { const requested = sessionTypes.find(type => type.id === options.sessionTypeId); - return requested && (!requiresWorktreeConfiguration || requested.supportsWorktreeConfiguration === true) - ? requested.id - : undefined; + return requested?.id; } - return (requiresWorktreeConfiguration - ? sessionTypes.find(type => type.supportsWorktreeConfiguration === true) - : sessionTypes[0])?.id; + return sessionTypes[0]?.id; }; if (options?.providerId) { @@ -456,6 +459,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } sessionTypeId = resolveSessionTypeId(provider); if (!sessionTypeId) { + if (requiresRepositorySource) { + throw new Error(localize('sessions.repositorySourceUnsupported', "Sessions provider '{0}' does not support the requested repository source or revision.", options.providerId)); + } if (requiresWorktreeConfiguration) { throw new Error(`Sessions provider '${options.providerId}' does not support worktree configuration for folder '${folderUri.toString()}'`); } @@ -480,6 +486,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa break; } if (!provider || !workspace) { + if (requiresRepositorySource) { + throw new Error(localize('sessions.noRepositorySourceProvider', "No sessions provider supports the requested repository source or revision.")); + } throw new Error(requiresWorktreeConfiguration ? `No sessions provider supports worktree configuration for folder '${folderUri.toString()}'` : `No sessions provider can resolve folder '${folderUri.toString()}'`); @@ -530,6 +539,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa * advertised one. Throws when no capable provider/type can be resolved. */ private _resolveProviderForQuickChat(options?: ICreateNewSessionOptions): { provider: ISessionsProvider; sessionTypeId: string } { + if (options?.repositorySource !== undefined || options?.repositoryRevision !== undefined) { + throw new Error(localize('sessions.repositoryQuickChat', "Repository inputs require a workspace-bound session, not a quick chat.")); + } const providers = this.sessionsProvidersService.getProviders(); let provider: ISessionsProvider | undefined; @@ -616,6 +628,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa : options?.automationConfiguration; return { metadata: options?.metadata, + ...(options?.repositorySource !== undefined ? { repositorySource: options.repositorySource } : {}), + ...(options?.repositoryRevision !== undefined ? { repositoryRevision: options.repositoryRevision } : {}), ...(automationConfiguration ? { automationConfiguration } : {}), }; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 98f4d6b8c6ee71..7908756da5dd99 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -25,6 +25,9 @@ export interface ISessionType { readonly icon: ThemeIcon; /** Whether new sessions of this type support Worktree isolation and base-branch selection. */ readonly supportsWorktreeConfiguration?: boolean; + /** Whether new sessions accept an explicit repository source. */ + readonly supportsRepositorySource?: boolean; + readonly supportsRepositoryRevision?: boolean; /** * The workbench chat session type (contribution id) this session type maps * to, when it differs from {@link id}. Agent-host providers use a bare agent diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 2949ec8b471e6d..154759b64bcdae 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -61,6 +61,9 @@ export interface IProviderSessionType { * Options for {@link ISessionsManagementService.createNewSession}. */ export interface ICreateNewSessionOptions { + /** Repository creation intent, not a resolved host directory. */ + readonly repositorySource?: URI; + readonly repositoryRevision?: string; /** * Force creation through a specific provider. When omitted, the service * iterates registered providers and picks the first one whose diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 872bfec9761efd..aa8cba533b0fa1 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -51,6 +51,8 @@ export interface ISendRequestOptions { /** Provider options applied when creating a new session draft. */ export interface ISessionsProviderCreateSessionOptions { + readonly repositorySource?: URI; + readonly repositoryRevision?: string; /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; /** Complete Automation state for providers that also own compatibility projections. */ diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 16e706251774df..4dba0ee71bfe71 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -2247,6 +2247,59 @@ suite('SessionsManagementService', () => { }); }); + for (const scenario of [ + { sourceSupport: false, revisionSupport: false, revision: undefined, accepted: false }, + { sourceSupport: true, revisionSupport: false, revision: 'main', accepted: false }, + { sourceSupport: true, revisionSupport: false, revision: undefined, accepted: true }, + { sourceSupport: true, revisionSupport: true, revision: 'main', accepted: true }, + ]) { + test(`typed repository source inputs require a supporting session type (${JSON.stringify(scenario)})`, () => { + const session = stubSession({ sessionId: 'repository-draft', providerId: 'test' }); + const repositorySource = URI.parse('https://git.example.org/team/app.git'); + const calls: (ISessionsProviderCreateSessionOptions | undefined)[] = []; + const provider = new class extends TestSessionsProvider { + override readonly sessionTypes: readonly ISessionType[] = [{ + id: 'test', label: 'Test', icon: Codicon.repo, authRequirement: SessionTypeAuthRequirement.None, + supportsRepositorySource: scenario.sourceSupport, + supportsRepositoryRevision: scenario.revisionSupport, + }]; + override resolveWorkspace(): ISessionWorkspace { + return { + uri: repositorySource, label: 'Repository', icon: Codicon.repo, requiresWorkspaceTrust: false, isVirtualWorkspace: true, + folders: [{ root: repositorySource, workingDirectory: repositorySource, name: 'Repository', description: undefined }], + }; + } + override createNewSession(_folder?: URI, _sessionType?: string, options?: ISessionsProviderCreateSessionOptions): ISession { + calls.push(options); + return session; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const create = () => service.createNewSession(repositorySource, { + providerId: provider.id, + repositorySource, + repositoryRevision: scenario.revision, + }); + if (scenario.accepted) { + create(); + } else { + assert.throws(create, /repository source or revision/); + } + assert.deepStrictEqual(calls, scenario.accepted ? [{ + metadata: undefined, + repositorySource, + ...(scenario.revision !== undefined ? { repositoryRevision: scenario.revision } : {}), + }] : []); + }); + } + + test('typed repository source options are rejected for invalid session modes', () => { + const session = stubSession({ sessionId: 'invalid-source', providerId: 'test' }); + const { service } = createSessionsManagementService(session, disposables); + assert.throws(() => service.createNewSession(URI.file('/workspace'), { repositoryRevision: 'main' }), /requires a repository source/); + assert.throws(() => service.createQuickChat({ repositorySource: URI.parse('https://example.com/team/project') }), /Repository inputs require a workspace-bound session/); + }); + test('createAndSendNewChatRequest rejects canonical Automation templates for providers without restoration support', async () => { const session = stubSession({ sessionId: 's1', diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts index 5b8a14e09ae73b..3da94dc23cecf0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts @@ -7,97 +7,73 @@ import { raceCancellationError } from '../../../../../../base/common/async.js'; import { CancellationToken } 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 { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { validateRepositorySource } from '../../../../../../platform/agentHost/common/agentHostRepositorySource.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; -import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { JsonRpcErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; -import { ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; -import { SessionConfigState, SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { RepositorySourceCapability } from '../../../../../../platform/agentHost/common/state/protocol/channels-root/state.js'; +import { SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -/** Validate the standard repository inputs advertised in the configuration schema. */ -export function supportsRepositorySessionConfig(schema: SessionConfigSchema | undefined): boolean { - const properties = schema?.properties; - const hasSource = properties && Object.hasOwn(properties, SessionConfigKey.RepositorySource); - const hasRevision = properties && Object.hasOwn(properties, SessionConfigKey.RepositoryRevision); - if (!hasSource && !hasRevision) { - return false; +/** Read the per-agent capability, independently of provider configuration. */ +export function getRepositorySourceCapability(connection: IAgentConnection, provider: string): RepositorySourceCapability | undefined { + const root = connection.rootState.value; + if (root instanceof Error) { + throw root; } - const isInput = (property: string) => properties?.[property]?.type === 'string' - && properties[property].readOnly !== true && properties[property].sessionMutable !== true; - if (!hasSource || !isInput(SessionConfigKey.RepositorySource) || (hasRevision && !isInput(SessionConfigKey.RepositoryRevision))) { - throw new Error(localize('agentHost.invalidRepositoryConfig', "The agent host advertised an invalid repository configuration.")); + const capability = root?.agents.find(agent => agent.provider === provider)?.capabilities?.repositorySource; + if (capability === undefined) { + return undefined; + } + if (!capability || typeof capability !== 'object' || Array.isArray(capability) + || (capability.revision !== undefined && typeof capability.revision !== 'boolean')) { + throw new Error(localize('agentHost.invalidRepositoryCapability', "The agent host advertised an invalid repository source capability.")); + } + return capability; +} + +/** Preserve HTTPS repository selections without treating ordinary file directories as sources. */ +export function getRepositorySourceFromSelection(connection: IAgentConnection, provider: string, selected: URI | undefined): URI | undefined { + if (selected?.scheme !== Schemas.https) { + return undefined; } - return true; + const defaultDirectory = connection.initializeResult.get()?.defaultDirectory; + const defaultScheme = defaultDirectory ? (URI.isUri(defaultDirectory) ? URI.revive(defaultDirectory) : URI.parse(defaultDirectory)).scheme : undefined; + return defaultScheme !== Schemas.https + && getRepositorySourceCapability(connection, provider) ? selected : undefined; } -/** Read the standard repository source and validate its optional revision. */ -export function getRepositorySessionSource(config: SessionConfigState | undefined): string | undefined { - const supported = supportsRepositorySessionConfig(config?.schema); - const value = config?.values[SessionConfigKey.RepositorySource]; - const revision = config?.values[SessionConfigKey.RepositoryRevision]; +/** Read immutable requested intent from session metadata, including restored sessions. */ +export function getRepositorySessionSource(state: Pick | undefined): string | undefined { + const value = state?.repositorySource; + const revision = state?.repositoryRevision; if (value === undefined && revision === undefined) { return undefined; } - if (!supported || typeof value !== 'string' || !value.trim()) { + if (typeof value !== 'string' || !value.trim()) { throw new Error(localize('agentHost.invalidRepositoryValue', "The agent host returned an invalid repository selection.")); } - if (revision !== undefined) { - if (!config || !Object.hasOwn(config.schema.properties, SessionConfigKey.RepositoryRevision)) { - throw new Error(localize('agentHost.unsupportedRepositoryRevision', "The agent host does not advertise repository revision selection.")); - } - if (typeof revision !== 'string' || !revision.trim()) { - throw new Error(localize('agentHost.invalidRepositoryRevision', "The repository revision must be a nonempty string.")); - } + if (revision !== undefined && (typeof revision !== 'string' || !revision.trim())) { + throw new Error(localize('agentHost.invalidRepositoryRevision', "The repository revision must be a nonempty string.")); } return value; } -/** Resolve a selected repository through advertised session configuration; absence retains the legacy path. */ -export async function resolveAgentHostRepositoryConfig(connection: IAgentConnection, provider: string, repository: URI, config: Record | undefined, token: CancellationToken): Promise | undefined> { +/** Resolve provider configuration with typed repository context, without preparing a checkout. */ +export async function resolveAgentHostRepositoryConfig(connection: IAgentConnection, provider: string, repository: URI, config: Record | undefined, token: CancellationToken, revision?: string): Promise> { if (token.isCancellationRequested) { throw new CancellationError(); } - if (!repository.authority || repository.authority.includes('@') || repository.query || repository.fragment) { - throw new Error(localize('agentHost.invalidRepositoryUri', "Select a repository URL without credentials, a query, or a fragment.")); - } - const source = repository.toString(); - const existing = config?.[SessionConfigKey.RepositorySource]; - if (existing !== undefined && existing !== source) { - throw new Error(localize('agentHost.conflictingRepository', "The selected repository conflicts with the session configuration.")); - } - const hasExplicitRepositoryConfig = existing !== undefined || config?.[SessionConfigKey.RepositoryRevision] !== undefined; - let initial: ResolveSessionConfigResult; - try { - initial = await raceCancellationError(connection.resolveSessionConfig({ provider, config }), token); - } catch (error) { - if (error instanceof ProtocolError && error.code === JsonRpcErrorCodes.MethodNotFound && !hasExplicitRepositoryConfig) { - return undefined; - } - throw error; - } - if (!supportsRepositorySessionConfig(initial.schema)) { - if (hasExplicitRepositoryConfig) { - throw new Error(localize('agentHost.unsupportedRepositoryConfig', "The agent host does not advertise repository-backed session creation.")); - } - return undefined; - } - const requested = { ...initial.values, ...config, [SessionConfigKey.RepositorySource]: source }; - getRepositorySessionSource({ schema: initial.schema, values: requested }); - const resolved = await raceCancellationError(connection.resolveSessionConfig({ provider, config: requested }), token); - if (!supportsRepositorySessionConfig(resolved.schema)) { - throw new Error(localize('agentHost.repositoryConfigChanged', "The agent host changed its repository configuration while resolving the session.")); - } - const values = { ...resolved.values, ...config, [SessionConfigKey.RepositorySource]: source }; - getRepositorySessionSource({ schema: resolved.schema, values }); - return values; + const inputs = { repositorySource: repository, ...(revision !== undefined ? { repositoryRevision: revision } : {}), config }; + validateRepositorySource(inputs, getRepositorySourceCapability(connection, provider)); + const resolved = await raceCancellationError(connection.resolveSessionConfig({ provider, ...inputs }), token); + validateRepositorySource({ ...inputs, config: resolved.values }, getRepositorySourceCapability(connection, provider)); + return resolved.values; } /** Wait for opted-in repository initialization, preserving other sessions' existing lifecycle handling. */ -export function waitForRepositorySessionReady(subscription: IAgentSubscription, token: CancellationToken, expectedRepository?: URI, expectedConfig?: Readonly>): Promise { +export function waitForRepositorySessionReady(subscription: IAgentSubscription, token: CancellationToken, expectedRepository?: URI, expectedRevision?: string): Promise { return new Promise((resolve, reject) => { const store = new DisposableStore(); const fail = (error: unknown) => { @@ -116,7 +92,7 @@ export function waitForRepositorySessionReady(subscription: IAgentSubscription typeof directory !== 'string' || !URI.parse(directory).scheme)) { throw new Error(localize('agentHost.repositoryNotReady', "The agent host did not report a ready checkout for the selected repository.")); 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 e0ad21821c0be2..3c018e70870321 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -28,7 +28,8 @@ import { isLocation, type Location } from '../../../../../../editor/common/langu import type { ITextModel } from '../../../../../../editor/common/model.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostAllowSignedOutWhenUsableSettingId, AgentProvider, AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostAllowSignedOutWhenUsableSettingId, AgentProvider, AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentConnection, type IAgentCreateSessionConfig } from '../../../../../../platform/agentHost/common/agentService.js'; +import { validateRepositorySource } from '../../../../../../platform/agentHost/common/agentHostRepositorySource.js'; import { agentHostAuthority, LOCAL_AGENT_HOST_AUTHORITY } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { isCustomizationEnabled } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { findDeepestContainingWorkingDirectory } from '../../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; @@ -51,7 +52,7 @@ import { compareProtocolVersions } from '../../../../../../platform/agentHost/co import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AhpErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; -import { getRepositorySessionSource, resolveAgentHostRepositoryConfig, waitForRepositorySessionReady } from './agentHostRepositoryConfig.js'; +import { getRepositorySessionSource, getRepositorySourceCapability, getRepositorySourceFromSelection, resolveAgentHostRepositoryConfig, waitForRepositorySessionReady } from './agentHostRepositoryConfig.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readMessageSystemInitiatedLabel, 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 ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -1816,6 +1817,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC cancellationToken: CancellationToken, ): Promise { this._logService.info(`[AgentHost] _invokeAgent called for resource: ${request.sessionResource.toString()}`); + const requestedRepository = request.agentHostRepositorySource ?? this._repositorySourceFromSelection(request.sessionResource); // Gate spawning an agent on workspace trust. Viewing chat and the // agent list does not require trust, but sending a message does, since @@ -1828,7 +1830,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // `undefined` and skips the gate entirely: its only cwd is an internal // scratch dir, not a user workspace. If the user declines, abort without // starting a session. - const trustFolders = await this._resolveSessionTrustFolders(request.sessionResource, cancellationToken); + const trustFolders = await this._resolveSessionTrustFolders(request.sessionResource, cancellationToken, requestedRepository); if (cancellationToken.isCancellationRequested) { return {}; } @@ -1872,7 +1874,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // open with hydrated state. Use the unmanaged accessor to peek // without taking a fresh subscription, which would trigger a // duplicate snapshot fetch and (in tests) unrelated mock behaviour. - const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken); + const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken, requestedRepository, request.agentHostRepositoryRevision); if (cancellationToken.isCancellationRequested) { return {}; } @@ -1901,13 +1903,23 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; await this._createAndSubscribe( request.sessionResource, - model, - Object.keys(initialConfig).length > 0 ? initialConfig : undefined, - imported ? { turns: imported.turns, model: imported.model } : undefined, + { + model, + config: Object.keys(initialConfig).length > 0 ? initialConfig : undefined, + importConversation: imported ? { turns: imported.turns, model: imported.model } : undefined, + repositorySource: requestedRepository, + repositoryRevision: request.agentHostRepositoryRevision, + }, stage => failureStage = stage, cancellationToken, ); } else { + if (getRepositorySessionSource(existingState) !== undefined) { + const roots = existingState.workingDirectories?.map(directory => this._config.connection.resourceUris.fromAgentHost(URI.parse(directory))) ?? []; + if (roots.some(root => root.scheme === Schemas.file) && !await this._ensureFoldersTrusted(roots)) { + throw new CancellationError(); + } + } failureStage = 'authentication'; await this._ensureRequiredAuthentication(this._createModelSelection(request.userSelectedModelId, request.modelConfiguration)); @@ -2047,7 +2059,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * returning. This closes a race where the chat request arrives between * `createSession` resolving and the snapshot landing. */ - private async _readEagerlyCreatedSessionState(resolvedSession: URI, token: CancellationToken): Promise { + private async _readEagerlyCreatedSessionState(resolvedSession: URI, token: CancellationToken, repositorySource?: URI, repositoryRevision?: string): Promise { // If the sessions provider's eager `createSession` is still in flight, wait for it so its IIFE has a chance to // open the state subscription before we fall through to a duplicate `_createAndSubscribe` below. Both we and // the IIFE await the same promise object, so microtask FIFO runs the IIFE's continuation first (it registered @@ -2071,7 +2083,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (sub.value instanceof Error) { return undefined; } - if (sub.value !== undefined && getRepositorySessionSource(sub.value.config) === undefined) { + if (sub.value !== undefined && !repositorySource && getRepositorySessionSource(sub.value) === undefined) { return sub.value; } @@ -2089,7 +2101,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._whenSubscriptionHydrated(pinRef.object, token); const value = pinRef.object.value; this._logService.info(`[AgentHost] _readEagerlyCreatedSessionState: hydrated value=${value === undefined ? 'undefined' : value instanceof Error ? `error(${value.message})` : 'state'} cancelled=${token.isCancellationRequested} for ${resolvedSession.toString()}`); - return value instanceof Error || value === undefined ? undefined : await waitForRepositorySessionReady(pinRef.object, token); + return value instanceof Error || value === undefined ? undefined : await waitForRepositorySessionReady(pinRef.object, token, repositorySource, repositoryRevision); } finally { pinRef.dispose(); } @@ -5594,7 +5606,9 @@ 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, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void, cancellationToken: CancellationToken = CancellationToken.None): Promise { + private async _createAndSubscribe(sessionResource: URI, options: Pick, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void, cancellationToken: CancellationToken = CancellationToken.None): Promise { + const { model, importConversation, repositoryRevision } = options; + let { config } = options; let workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); const requestedSession = this._resolveSessionUri(sessionResource); const meta = this._provisionalService.getInitialSessionMetadata(sessionResource); @@ -5604,25 +5618,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC onFailureStage?.('authentication'); const protectedResources = await this._ensureRequiredAuthentication(model); - const requestedDirectory = this._resolveRequestedWorkingDirectory(sessionResource); - const defaultDirectory = this._config.connection.initializeResult.get()?.defaultDirectory; - const defaultScheme = defaultDirectory ? (URI.isUri(defaultDirectory) ? URI.revive(defaultDirectory) : URI.parse(defaultDirectory)).scheme : undefined; - let repository: URI | undefined; - if (requestedDirectory?.scheme === Schemas.https && defaultScheme !== Schemas.https) { - const repositoryConfig = await resolveAgentHostRepositoryConfig(this._config.connection, this._config.provider, requestedDirectory, config, cancellationToken); - if (repositoryConfig) { - config = repositoryConfig; - workingDirectories = undefined; - repository = requestedDirectory; - } else { - this._logService.info('[AgentHost] Repository session configuration is not advertised; retaining the host-selected directory behavior.'); - } + const repository = options.repositorySource ?? this._repositorySourceFromSelection(sessionResource); + validateRepositorySource({ repositorySource: repository, repositoryRevision, config }, repository || repositoryRevision !== undefined ? getRepositorySourceCapability(this._config.connection, this._config.provider) : undefined); + if (repository) { + config = await resolveAgentHostRepositoryConfig(this._config.connection, this._config.provider, repository, config, cancellationToken, repositoryRevision); + workingDirectories = undefined; } if (cancellationToken.isCancellationRequested) { throw new CancellationError(); } - const activeClientEntry = this._ensureActiveClientEntry(sessionResource); + const activeClientEntry = this._ensureActiveClientEntry(sessionResource, repository ? [] : undefined); await raceCancellationError(activeClientEntry.whenSettled(), cancellationToken); const activeClient = this._getCurrentActiveClient(sessionResource); @@ -5641,6 +5647,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC model, provider: this._config.provider, workingDirectories, + ...(repository !== undefined ? { repositorySource: repository } : {}), + ...(repositoryRevision !== undefined ? { repositoryRevision } : {}), config, importConversation, activeClient, @@ -5662,6 +5670,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC model, provider: this._config.provider, workingDirectories, + ...(repository !== undefined ? { repositorySource: repository } : {}), + ...(repositoryRevision !== undefined ? { repositoryRevision } : {}), config, importConversation, activeClient, @@ -5686,7 +5696,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Subscribe to the new session's state onFailureStage?.('subscribeSession'); const newSub = this._ensureSessionSubscription(session.toString()); - this._configureActiveClientReconciliation(sessionResource, session, newSub); + if (!repository) { + this._configureActiveClientReconciliation(sessionResource, session, newSub); + } if (!this._getSessionState(session.toString())) { // Wait for the subscription to hydrate. `_whenSubscriptionHydrated` // settles on snapshot, error, or cancellation and attaches its @@ -5700,8 +5712,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._whenSubscriptionHydrated(newSub, cancellationToken); } - const rawState = await waitForRepositorySessionReady(newSub, cancellationToken, repository, config); - if (getRepositorySessionSource(rawState.config) !== undefined) { + const rawState = await waitForRepositorySessionReady(newSub, cancellationToken, repository, repositoryRevision); + if (getRepositorySessionSource(rawState) !== undefined) { const roots = rawState.workingDirectories?.map(directory => this._config.connection.resourceUris.fromAgentHost(URI.parse(directory))) ?? []; if (roots.some(root => root.scheme === Schemas.file) && !await this._ensureFoldersTrusted(roots)) { throw new CancellationError(); @@ -6112,6 +6124,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; } + private _repositorySourceFromSelection(sessionResource: URI): URI | undefined { + return getRepositorySourceFromSelection(this._config.connection, this._config.provider, this._resolveRequestedWorkingDirectory(sessionResource)); + } + /** `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); @@ -6182,19 +6198,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return dirs.map(directory => typeof directory === 'string' ? URI.parse(directory) : directory); } - /** - * Resolves the local folders the agent will run in, for the workspace-trust - * gate: an existing session's persisted working directories, or a new session's - * requested ones. - * - * Returns `undefined` for a workspace-less session (a quick chat) to signal the - * caller to skip the folder-trust gate entirely: its only working directory is - * an internal scratch dir (`~/.copilot/chats/`), an implementation detail - * rather than a user workspace, so it must not be treated as a trust root. - * Otherwise an explicit empty set is honored and only a genuinely unresolved set - * falls back to the requested/workspace folders. - */ - private async _resolveSessionTrustFolders(sessionResource: URI, token: CancellationToken): Promise { + /** Resolve directory-session trust roots; repository sessions check their resolved roots after readiness. */ + private async _resolveSessionTrustFolders(sessionResource: URI, token: CancellationToken, repositorySource?: URI): Promise { + // Repository sessions check the host-resolved roots after preparation, not the current workspace. + if (repositorySource) { + return undefined; + } if (!this._isNewSessionResource(sessionResource)) { // Read the authoritative session state once — prefer already-hydrated // handler-level state, otherwise the eager/connection-level state — so @@ -6207,6 +6216,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (state?.workingDirectories === undefined) { state = await this._readEagerlyCreatedSessionState(backendSession, token) ?? state; } + if (getRepositorySessionSource(state) !== undefined) { + return undefined; + } // A workspace-less session (quick chat) runs only in an internal scratch // dir that is not a user workspace; never gate trust on it. if (state && readSessionWorkspaceless(state._meta)) { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index d07ca41885732b..c0e4ae1e79701f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1954,6 +1954,8 @@ export interface IChatSendRequestOptions { attachedContext?: IChatRequestVariableEntry[]; resolvedVariables?: IChatRequestVariableEntry[]; agentHostSessionConfig?: Record; + agentHostRepositorySource?: URI; + agentHostRepositoryRevision?: string; /** Provider-specific request metadata, separate from the prompt. */ metadata?: Record; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index db144cd657feae..e5352b41b8ab84 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1756,6 +1756,8 @@ export class ChatService extends Disposable implements IChatService { acceptedConfirmationData: options?.acceptedConfirmationData, rejectedConfirmationData: options?.rejectedConfirmationData, agentHostSessionConfig: options?.agentHostSessionConfig, + ...(options?.agentHostRepositorySource !== undefined ? { agentHostRepositorySource: options.agentHostRepositorySource } : {}), + ...(options?.agentHostRepositoryRevision !== undefined ? { agentHostRepositoryRevision: options.agentHostRepositoryRevision } : {}), metadata: options?.metadata, userSelectedModelId: options?.userSelectedModelId, modelConfiguration: options?.userSelectedModelConfiguration ?? (options?.userSelectedModelId ? this.languageModelsService.getModelConfiguration(options.userSelectedModelId) : undefined), diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index c387da1676690c..34e70d123a42a8 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -155,6 +155,8 @@ export interface IChatAgentRequest { acceptedConfirmationData?: unknown[]; rejectedConfirmationData?: unknown[]; agentHostSessionConfig?: Record; + agentHostRepositorySource?: URI; + agentHostRepositoryRevision?: string; /** Provider-specific request metadata, separate from the prompt. */ metadata?: Record; userSelectedModelId?: string; 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 c840a37c48205e..8f9d73d5d70fab 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 @@ -289,6 +289,8 @@ class MockAgentHostService extends mock() { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: resolvedWorkingDir ? [resolvedWorkingDir] : undefined, + ...(config.repositorySource !== undefined ? { repositorySource: config.repositorySource.toString() } : {}), + ...(config.repositoryRevision !== undefined ? { repositoryRevision: config.repositoryRevision } : {}), }; const state: SessionState = { ...this._withDefaultChatCatalog(createSessionState(summary), session.toString()), @@ -397,6 +399,13 @@ class MockAgentHostService extends mock() { this._rootStateOnDidChange.fire(state); } + enableRepositorySource(): void { + this.setRootState({ + agents: [{ provider: 'copilot', displayName: 'Test', description: 'test', models: [], capabilities: { repositorySource: { revision: true } } }], + activeSessions: 0, + }); + } + public authenticateCalls: { resource: string; scopes?: readonly string[]; token: string }[] = []; override async authenticate(params: { resource: string; scopes?: readonly string[]; token: string }): Promise<{ authenticated: boolean }> { this.authenticateCalls.push({ resource: params.resource, scopes: params.scopes, token: params.token }); @@ -1143,7 +1152,7 @@ function createByokLanguageModelTestData(groupName?: string): { languageModels: }; } -function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string; requestId: string; acceptedConfirmationData: unknown[]; metadata: Record }> = {}): IChatAgentRequest { +function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentHostRepositorySource: URI; agentHostRepositoryRevision: string; agentId: string; requestId: string; acceptedConfirmationData: unknown[]; metadata: Record }> = {}): IChatAgentRequest { return upcastPartial({ sessionResource: overrides.sessionResource ?? URI.from({ scheme: 'untitled', path: '/chat-1' }), requestId: overrides.requestId ?? 'req-1', @@ -1154,6 +1163,8 @@ function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; userSelectedModelId: overrides.userSelectedModelId, modelConfiguration: overrides.modelConfiguration, agentHostSessionConfig: overrides.agentHostSessionConfig, + agentHostRepositorySource: overrides.agentHostRepositorySource, + agentHostRepositoryRevision: overrides.agentHostRepositoryRevision, acceptedConfirmationData: overrides.acceptedConfirmationData, metadata: overrides.metadata, }); @@ -10968,12 +10979,16 @@ suite('AgentHostChatContribution', () => { for (const alreadyExists of [false, true]) { for (const hasDefaultDirectory of [false, true]) { - test(`repository session uses standard config and reattaches after a lost response (${alreadyExists}, default directory ${hasDefaultDirectory})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test(`repository session uses typed source inputs and reattaches after a lost response (${alreadyExists}, default directory ${hasDefaultDirectory})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); + agentHostService.enableRepositorySource(); const repository = URI.parse('https://example.com/owner/repo'); const checkout = URI.file('/host/checkout'); const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: 'checkout-plugin', uri: 'file:///checkout-plugin', name: 'Checkout plugin' }]; disposables.add(seedActiveClient('repository-session', { customizations: constObservable(customizations) }, [checkout])); + disposables.add(seedActiveClient('repository-session', { + customizations: constObservable([{ type: CustomizationType.Plugin, id: 'unrelated-plugin', uri: 'file:///unrelated-plugin', name: 'Unrelated plugin' }]), + }, [URI.file('/unrelated/current-workspace')])); if (hasDefaultDirectory) { agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); } @@ -10984,7 +10999,7 @@ suite('AgentHostChatContribution', () => { agentHostService.repositorySessionConfig = { schema: { type: 'object', - properties: { repositorySource: { type: 'string', title: 'Repository' } }, + properties: {}, }, values: {}, }; @@ -10996,14 +11011,14 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', - resolveWorkingDirectory: () => repository, + resolveWorkingDirectory: () => hasDefaultDirectory ? URI.file('/unrelated/current-workspace') : repository, })); const resource = URI.from({ scheme: 'repository-session', path: '/new-repository' }); const chat = await handler.provideChatSessionContent(resource, CancellationToken.None); disposables.add(toDisposable(() => chat.dispose())); const registered = chatAgentService.registeredAgents.get('repository-session'); assert.ok(registered); - const turn = registered.impl.invoke(makeRequest({ agentId: 'repository-session', sessionResource: resource }), () => { }, [], CancellationToken.None); + const turn = registered.impl.invoke(makeRequest({ agentId: 'repository-session', sessionResource: resource, agentHostRepositorySource: repository, agentHostRepositoryRevision: 'main' }), () => { }, [], CancellationToken.None); await timeout(25); const dispatch = agentHostService.turnActions[0]; assert.ok(dispatch); @@ -11014,13 +11029,21 @@ suite('AgentHostChatContribution', () => { const lastActiveClient = agentHostService.dispatchedActions.findLast(entry => entry.action.type === ActionType.SessionActiveClientSet)?.action; assert.deepStrictEqual({ config: agentHostService.createSessionCalls[0].config, + initialCustomizations: agentHostService.createSessionCalls[0].activeClient?.customizations, + source: agentHostService.createSessionCalls[0].repositorySource?.toString(), + revision: agentHostService.createSessionCalls[0].repositoryRevision, workingDirectories: agentHostService.createSessionCalls[0].workingDirectories, discoveryDirectories: agentHostService.resolveSessionConfigCalls.map(call => call.workingDirectory), + discoverySources: agentHostService.resolveSessionConfigCalls.map(call => call.repositorySource?.toString()), customizations: lastActiveClient?.type === ActionType.SessionActiveClientSet ? lastActiveClient.activeClient.customizations : undefined, }, { - config: { repositorySource: repository.toString() }, + config: {}, + initialCustomizations: [], + source: repository.toString(), + revision: 'main', workingDirectories: undefined, - discoveryDirectories: [undefined, undefined], + discoveryDirectories: [undefined], + discoverySources: [repository.toString()], customizations, }); })); @@ -11029,12 +11052,13 @@ suite('AgentHostChatContribution', () => { test('repository session does not send the first turn until the host publishes ready', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + agentHostService.enableRepositorySource(); const repository = URI.parse('https://example.com/owner/repo'); agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); agentHostService.nextResolvedWorkingDirectory = URI.file('/host/checkout'); agentHostService.nextSessionLifecycle = SessionLifecycle.Creating; agentHostService.repositorySessionConfig = { - schema: { type: 'object', properties: { repositorySource: { type: 'string', title: 'Repository' } } }, + schema: { type: 'object', properties: {} }, values: {}, }; const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -11070,6 +11094,7 @@ suite('AgentHostChatContribution', () => { test('repository session verifies trust for a newly prepared local checkout before starting a turn', async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + agentHostService.enableRepositorySource(); const repository = URI.parse('https://example.com/owner/repo'); const checkout = URI.file('/new-local-checkout'); const trustRequests: string[] = []; @@ -11083,7 +11108,7 @@ suite('AgentHostChatContribution', () => { agentHostService.setInitializeResult({ defaultDirectory: URI.file('/host').toString() }); agentHostService.nextResolvedWorkingDirectory = checkout; agentHostService.repositorySessionConfig = { - schema: { type: 'object', properties: { repositorySource: { type: 'string', title: 'Repository' } } }, + schema: { type: 'object', properties: {} }, values: {}, }; disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts index ae98c92d868726..6a2a2957dc8686 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts @@ -7,39 +7,43 @@ import assert from 'assert'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentConnection, IAgentResolveSessionConfigParams } from '../../../../../../platform/agentHost/common/agentService.js'; +import { validateRepositorySource } from '../../../../../../platform/agentHost/common/agentHostRepositorySource.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; -import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { InitializeResult, ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { JsonRpcErrorCodes } from '../../../../../../platform/agentHost/common/state/protocol/errors.js'; import { ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { SessionConfigPropertySchema, SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; -import { SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { getRepositorySessionSource, resolveAgentHostRepositoryConfig, supportsRepositorySessionConfig, waitForRepositorySessionReady } from '../../../browser/agentSessions/agentHost/agentHostRepositoryConfig.js'; +import { SessionConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/channels-session/state.js'; +import { RepositorySourceCapability } from '../../../../../../platform/agentHost/common/state/protocol/channels-root/state.js'; +import { AgentCapabilities, AgentInfo, RootState, SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { getRepositorySessionSource, getRepositorySourceCapability, getRepositorySourceFromSelection, resolveAgentHostRepositoryConfig, waitForRepositorySessionReady } from '../../../browser/agentSessions/agentHost/agentHostRepositoryConfig.js'; const repository = URI.parse('https://example.com/owner/repo'); const schema: SessionConfigSchema = { type: 'object', properties: { - repositorySource: { type: 'string', title: 'Repository' }, - repositoryRevision: { type: 'string', title: 'Revision' }, branch: { type: 'string', title: 'Working branch' }, mode: { type: 'string', title: 'Mode' }, }, }; -const sourceOnlySchema: SessionConfigSchema = { - type: 'object', - properties: { repositorySource: { type: 'string', title: 'Repository' } }, -}; suite('AgentHostRepositoryConfig', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function connectionWithResponses(responses: readonly (ResolveSessionConfigResult | Error)[]) { + function connectionWithResponses(responses: readonly (ResolveSessionConfigResult | Error)[], capabilities: AgentCapabilities = { repositorySource: { revision: true } }) { const calls: IAgentResolveSessionConfigParams[] = []; + let root: RootState | Error = upcastPartial({ + agents: [upcastPartial({ provider: 'provider', capabilities })], + }); const connection = new class extends mock() { + override readonly rootState = upcastPartial>({ + get value() { return root; }, + }); + override readonly initializeResult = constObservable(upcastPartial({ defaultDirectory: 'file:///host' })); override async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const response = responses[calls.length]; calls.push(params); @@ -50,142 +54,126 @@ suite('AgentHostRepositoryConfig', () => { return response; } }(); - return { calls, connection }; + return { + calls, + connection, + capabilities, + setCapability(value: RepositorySourceCapability | undefined) { capabilities.repositorySource = value; }, + failRoot(error: Error) { root = error; }, + }; } - test('uses standard input names and preserves selected values and host defaults', async () => { + test('sends typed source and revision outside provider config', async () => { const h = connectionWithResponses([ - { schema, values: { mode: 'interactive' } }, - { schema, values: { mode: 'interactive', extra: 'host-default' } }, + { schema, values: { mode: 'plan', branch: 'feature', extra: 'host-default' } }, ]); - const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: 'main', branch: 'feature', mode: 'plan' }, CancellationToken.None); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { branch: 'feature', mode: 'plan' }, CancellationToken.None, 'main'); assert.deepStrictEqual({ calls: h.calls, config }, { calls: [ - { provider: 'provider', config: { repositoryRevision: 'main', branch: 'feature', mode: 'plan' } }, - { provider: 'provider', config: { repositoryRevision: 'main', branch: 'feature', mode: 'plan', repositorySource: repository.toString() } }, + { provider: 'provider', repositorySource: repository, repositoryRevision: 'main', config: { branch: 'feature', mode: 'plan' } }, ], - config: { mode: 'plan', repositoryRevision: 'main', branch: 'feature', repositorySource: repository.toString(), extra: 'host-default' }, + config: { mode: 'plan', branch: 'feature', extra: 'host-default' }, }); }); - test('an unadvertised source input preserves legacy host behavior', async () => { - const h = connectionWithResponses([{ schema: { type: 'object', properties: {} }, values: {} }]); - assert.strictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), undefined); - assert.strictEqual(h.calls.length, 1); + test('absent capability does not infer repository support from configuration properties', async () => { + const h = connectionWithResponses([], {}); + assert.strictEqual(getRepositorySourceCapability(h.connection, 'provider'), undefined); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), /does not support/); + assert.deepStrictEqual(h.calls, []); }); - test('repository-dependent defaults replace the initial context defaults', async () => { - const h = connectionWithResponses([ - { schema, values: { branch: 'previous-context', obsolete: 'old-default' } }, - { schema, values: { branch: 'repository-default', repositorySource: repository.toString() } }, - ]); - const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None); - assert.deepStrictEqual(config, { branch: 'repository-default', repositorySource: repository.toString() }); + test('uses the authoritative resolved configuration instead of overwriting normalized values', async () => { + const h = connectionWithResponses([{ schema, values: { mode: 'interactive', branch: 'repository-default' } }]); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { mode: 'plan', obsolete: 'old-default' }, CancellationToken.None); + assert.deepStrictEqual(config, { mode: 'interactive', branch: 'repository-default' }); }); - test('accepts a source input without optional revision support', async () => { - const h = connectionWithResponses([ - { schema: sourceOnlySchema, values: {} }, - { schema: sourceOnlySchema, values: {} }, - ]); - assert.deepStrictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), { - repositorySource: repository.toString(), + test('accepts an empty source capability and omits an unused revision', async () => { + const h = connectionWithResponses([{ schema, values: {} }], { repositorySource: {} }); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None); + assert.deepStrictEqual({ calls: h.calls, config }, { + calls: [{ provider: 'provider', repositorySource: repository, config: undefined }], + config: {}, }); }); - test('host-specific field names do not advertise the standard capability', () => { - assert.strictEqual(supportsRepositorySessionConfig({ - type: 'object', - properties: { - source: { type: 'string', title: 'Source' }, - repositoryUrl: { type: 'string', title: 'Repository' }, - }, - }), false); + test('does not mistake a local working directory for a repository source', () => { + const h = connectionWithResponses([]); + assert.strictEqual(getRepositorySourceFromSelection(h.connection, 'provider', URI.file('/workspace')), undefined); }); - test('an older host without configuration discovery preserves legacy behavior', async () => { - const h = connectionWithResponses([new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Unsupported')]); - assert.strictEqual(await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), undefined); + test('an HTTPS selection requires the per-agent capability', () => { + const h = connectionWithResponses([], {}); + assert.strictEqual(getRepositorySourceFromSelection(h.connection, 'provider', repository), undefined); }); test('an advertised feature failing later is not treated as an unsupported host', async () => { const error = new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Configuration became unavailable'); - const h = connectionWithResponses([{ schema, values: {} }, error]); + const h = connectionWithResponses([error]); await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), error); }); - for (const property of ['repositorySource', 'repositoryRevision']) { - for (const invalidProperty of [ - { type: 'string', title: 'Repository input', readOnly: true }, - { type: 'string', title: 'Repository input', sessionMutable: true }, - { type: 'boolean', title: 'Repository input' }, - ] satisfies SessionConfigPropertySchema[]) { - test(`rejects an invalid standard input (${property}, ${JSON.stringify(invalidProperty)})`, () => { - assert.throws(() => supportsRepositorySessionConfig({ - ...schema, - properties: { ...schema.properties, [property]: invalidProperty }, - }), /invalid repository configuration/); - }); - } + for (const value of [null, true, [], { revision: 'yes' }]) { + test(`rejects a malformed source capability (${JSON.stringify(value)})`, () => { + const h = connectionWithResponses([]); + Object.assign(h.capabilities, { repositorySource: value }); + assert.throws(() => getRepositorySourceCapability(h.connection, 'provider'), /invalid repository source capability/); + }); } - test('rejects revision support without a source input', () => { - assert.throws(() => supportsRepositorySessionConfig({ - type: 'object', - properties: { repositoryRevision: { type: 'string', title: 'Revision' } }, - }), /invalid repository configuration/); + test('a root-state error is not an unsupported-capability fallback', () => { + const h = connectionWithResponses([]); + const error = new Error('Root subscription failed'); + h.failRoot(error); + assert.throws(() => getRepositorySourceCapability(h.connection, 'provider'), error); }); - for (const config of [ - { repositorySource: repository.toString() }, - { repositoryRevision: 'main' }, - ]) { - test(`does not discard explicit inputs on an unsupported host (${JSON.stringify(config)})`, async () => { - const h = connectionWithResponses([{ schema: { type: 'object', properties: {} }, values: {} }]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, config, CancellationToken.None), /does not advertise/); - }); - - test(`does not discard explicit inputs when discovery is unsupported (${JSON.stringify(config)})`, async () => { - const error = new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Unsupported'); - const h = connectionWithResponses([error]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, config, CancellationToken.None), error); + for (const key of ['repositorySource', 'repositoryRevision', 'repositoryUrl']) { + test(`rejects the obsolete config alias ${key}`, async () => { + const h = connectionWithResponses([]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { [key]: null }, CancellationToken.None), /request fields, not configuration/); + assert.deepStrictEqual(h.calls, []); }); } - test('rejects a requested revision that the host does not advertise', async () => { - const h = connectionWithResponses([{ schema: sourceOnlySchema, values: {} }]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: 'main' }, CancellationToken.None), /does not advertise repository revision/); - assert.strictEqual(h.calls.length, 1); + test('rejects a revision without a source', () => { + assert.throws(() => validateRepositorySource({ repositoryRevision: 'main' }, { revision: true }), /requires a repository source/); + }); + + test('rejects a requested revision without the revision capability', async () => { + const h = connectionWithResponses([], { repositorySource: {} }); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None, 'main'), /does not support repository revision/); + assert.deepStrictEqual(h.calls, []); }); test('fails if source support disappears during resolution', async () => { - const h = connectionWithResponses([ - { schema, values: {} }, - { schema: { type: 'object', properties: {} }, values: {} }, - ]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), /changed its repository configuration/); + const h = connectionWithResponses([{ schema, values: {} }]); + const pending = resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None); + h.setCapability(undefined); + await assert.rejects(pending, /does not support repository-backed/); }); - test('fails if support for a requested revision disappears during resolution', async () => { - const h = connectionWithResponses([ - { schema, values: {} }, - { schema: sourceOnlySchema, values: {} }, - ]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: 'main' }, CancellationToken.None), /does not advertise repository revision/); + test('fails if support for the requested revision disappears during resolution', async () => { + const h = connectionWithResponses([{ schema, values: {} }]); + const pending = resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None, 'main'); + h.setCapability({}); + await assert.rejects(pending, /does not support repository revision/); }); - for (const revision of [null, 17, '', ' ']) { + for (const revision of ['', ' ']) { test(`rejects an invalid explicit revision (${JSON.stringify(revision)})`, async () => { - const h = connectionWithResponses([{ schema, values: {} }]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositoryRevision: revision }, CancellationToken.None), /nonempty string/); - assert.strictEqual(h.calls.length, 1); + const h = connectionWithResponses([]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None, revision), /nonempty string/); + assert.deepStrictEqual(h.calls, []); }); } - test('does not silently replace an explicitly configured repository', async () => { - const h = connectionWithResponses([]); - await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { repositorySource: 'https://example.com/another/repo' }, CancellationToken.None), /conflicts/); - assert.deepStrictEqual(h.calls, []); + test('allows a file source without treating it as the resulting working directory', async () => { + const h = connectionWithResponses([{ schema, values: {} }]); + const source = URI.file('/source/repository'); + await resolveAgentHostRepositoryConfig(h.connection, 'provider', source, undefined, CancellationToken.None); + assert.deepStrictEqual(h.calls, [{ provider: 'provider', repositorySource: source, config: undefined }]); }); test('does not send credential-bearing repository URLs', async () => { @@ -202,26 +190,27 @@ suite('AgentHostRepositoryConfig', () => { for (const source of [null, 17, '', ' ']) { test(`rejects an invalid source in session state (${JSON.stringify(source)})`, () => { - assert.throws(() => getRepositorySessionSource({ schema, values: { repositorySource: source } }), /invalid repository selection/); + const state = session(SessionLifecycle.Ready); + Object.assign(state, { repositorySource: source }); + assert.throws(() => getRepositorySessionSource(state), /invalid repository selection/); }); } test('rejects a revision without a source in session state', () => { - assert.throws(() => getRepositorySessionSource({ schema, values: { repositoryRevision: 'main' } }), /invalid repository selection/); + assert.throws(() => getRepositorySessionSource({ repositoryRevision: 'main' }), /invalid repository selection/); }); - test('rejects unadvertised repository state rather than treating it as a directory session', () => { - assert.throws(() => getRepositorySessionSource({ - schema: { type: 'object', properties: {} }, - values: { repositorySource: repository.toString() }, - }), /invalid repository selection/); + test('reads source identity independently of provider configuration', () => { + const state = { ...session(SessionLifecycle.Ready), config: { schema, values: { repositorySource: 'https://example.com/not-the-source' } } }; + assert.strictEqual(getRepositorySessionSource(state), repository.toString()); }); function session(lifecycle: SessionLifecycle, withRepository = true): SessionState { return upcastPartial({ lifecycle, workingDirectories: lifecycle === SessionLifecycle.Ready ? ['file:///checkout/repo'] : undefined, - config: withRepository ? { schema, values: { repositorySource: repository.toString() } } : undefined, + repositorySource: withRepository ? repository.toString() : undefined, + config: { schema, values: {} }, }); } @@ -295,18 +284,23 @@ suite('AgentHostRepositoryConfig', () => { }); test('lost-response recovery must match the originally requested repository', async () => { - const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { repositorySource: 'https://example.com/another/repo' } } }); + const h = subscription({ ...session(SessionLifecycle.Ready), repositorySource: 'https://example.com/another/repo' }); await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository), /did not report a ready checkout/); }); test('lost-response recovery must also preserve an explicitly requested revision', async () => { - const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema, values: { repositorySource: repository.toString(), repositoryRevision: 'other' } } }); - await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, { repositoryRevision: 'main' }), /did not report a ready checkout/); + const h = subscription({ ...session(SessionLifecycle.Ready), repositoryRevision: 'other' }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, 'main'), /did not report a ready checkout/); + }); + + test('lost-response recovery distinguishes an omitted revision from an explicit revision', async () => { + const h = subscription({ ...session(SessionLifecycle.Ready), repositoryRevision: 'main' }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository), /did not report a ready checkout/); }); - test('lost-response recovery does not forget a requested revision when its schema entry disappears', async () => { - const h = subscription({ ...session(SessionLifecycle.Ready), config: { schema: sourceOnlySchema, values: { repositorySource: repository.toString() } } }); - await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, { repositoryRevision: 'main' }), /did not report a ready checkout/); + test('lost-response recovery does not forget a requested revision when provider config is absent', async () => { + const h = subscription({ ...session(SessionLifecycle.Ready), config: undefined }); + await assert.rejects(waitForRepositorySessionReady(h.sub, CancellationToken.None, repository, 'main'), /did not report a ready checkout/); }); test('one repository can resolve to multiple working directories', async () => { @@ -315,8 +309,8 @@ suite('AgentHostRepositoryConfig', () => { assert.strictEqual(await waitForRepositorySessionReady(h.sub, CancellationToken.None, repository), state); }); - test('advertising repository inputs without selecting a source preserves directory session behavior', async () => { - const state = { ...session(SessionLifecycle.Creating), config: { schema, values: { mode: 'interactive' } } }; + test('provider config does not opt a directory session into repository initialization', async () => { + const state = { ...session(SessionLifecycle.Creating, false), config: { schema, values: { repositorySource: repository.toString() } } }; const h = subscription(state); assert.strictEqual(await waitForRepositorySessionReady(h.sub, CancellationToken.None), state); });