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/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 7dc5824dd8a371..dd4d0d407a76f0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -fd0471d4 +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 019f367dad0c09..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,6 +79,9 @@ export interface ListSessionsResult extends PaginatedResult { * the full current property set (not a delta). The returned `values` contain * server-resolved defaults to pass to `createSession`. * + * This command MUST NOT clone or prepare a repository. Repository context + * requires the agent's `repositorySource` capability. + * * @category Commands * @method resolveSessionConfig * @direction Client → Server @@ -130,7 +133,11 @@ export interface ResolveSessionConfigParams extends BaseParams { provider?: string; /** Working directory for the session */ workingDirectory?: URI; - /** Current user-filled configuration values */ + /** 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; } @@ -195,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 4032839fbc0690..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 @@ -176,6 +176,8 @@ 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. + * 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 3492da93cad484..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,6 +23,9 @@ import type { MessageAttachment } from '../channels-chat/state.js'; * updates. The server also broadcasts a `root/sessionAdded` notification to all * clients. * + * Repository preparation MUST finish before `session/ready` or executing turns. + * Clients recover the outcome from session state, not progress notifications. + * * @category Commands * @method createSession * @direction Client → Server @@ -64,11 +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 `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; /** - * Agent-specific configuration values collected via `resolveSessionConfig`. - * Keys and values correspond to the schema returned by the server. + * Session configuration values collected via `resolveSessionConfig`. + * Keys and values follow the advertised {@link SessionConfigSchema}. */ config?: Record; /** @@ -101,6 +111,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..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,7 +178,7 @@ export interface SessionState extends SessionMetadata { * this over the session's lifetime. */ defaultChat?: URI; - /** Session configuration schema and current values */ + /** Provider-specific session configuration schema and current values. */ config?: SessionConfigState; /** * Top-level customizations active in this session. 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 453ccc1b46115c..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 @@ -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. 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. 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 and ordinary provider 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/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/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..3da94dc23cecf0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRepositoryConfig.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { validateRepositorySource } from '../../../../../../platform/agentHost/common/agentHostRepositorySource.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { RepositorySourceCapability } from '../../../../../../platform/agentHost/common/state/protocol/channels-root/state.js'; +import { SessionLifecycle, SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; + +/** 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 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; + } + 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 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 (typeof value !== 'string' || !value.trim()) { + throw new Error(localize('agentHost.invalidRepositoryValue', "The agent host returned an invalid repository selection.")); + } + 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 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(); + } + 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, expectedRevision?: string): 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); + 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 actualRevision = state.repositoryRevision; + if (state.lifecycle !== SessionLifecycle.Ready || !repository + || (expectedRepository && repository !== expectedRepository.toString()) + || ((expectedRepository !== undefined || 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..3c018e70870321 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'; @@ -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'; @@ -50,6 +51,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, 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'; @@ -932,6 +935,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 +1488,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. @@ -1812,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 @@ -1824,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 {}; } @@ -1868,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 {}; } @@ -1897,12 +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)); @@ -2042,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 @@ -2063,8 +2080,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 && !repositorySource && getRepositorySessionSource(sub.value) === undefined) { + return sub.value; } // Snapshot is in flight. Pin the subscription with a fresh @@ -2081,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 ? undefined : value; + return value instanceof Error || value === undefined ? undefined : await waitForRepositorySessionReady(pinRef.object, token, repositorySource, repositoryRevision); } finally { pinRef.dispose(); } @@ -2323,15 +2343,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 +5606,10 @@ 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, 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); @@ -5593,8 +5618,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC onFailureStage?.('authentication'); const protectedResources = await this._ensureRequiredAuthentication(model); - const activeClientEntry = this._ensureActiveClientEntry(sessionResource); - await activeClientEntry.whenSettled(); + 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, repository ? [] : undefined); + await raceCancellationError(activeClientEntry.whenSettled(), cancellationToken); const activeClient = this._getCurrentActiveClient(sessionResource); // Opt in to bring-up progress (chiefly the lazy first-use SDK download) @@ -5612,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, @@ -5619,7 +5656,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); @@ -5631,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, @@ -5655,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 @@ -5666,10 +5709,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, 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(); + } + 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); @@ -6072,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); @@ -6142,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 @@ -6167,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 e96c92f5e55b01..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 @@ -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); @@ -276,15 +289,24 @@ 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()), - 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; } @@ -377,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 }); @@ -1123,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', @@ -1134,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, }); @@ -10946,6 +10977,161 @@ 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 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() }); + } + agentHostService.nextResolvedWorkingDirectory = checkout; + if (alreadyExists) { + agentHostService.nextCreateSessionResponseError = new ProtocolError(AhpErrorCodes.SessionAlreadyExists, 'Session already created'); + } + agentHostService.repositorySessionConfig = { + schema: { + type: 'object', + properties: {}, + }, + 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: () => 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, agentHostRepositorySource: repository, agentHostRepositoryRevision: 'main' }), () => { }, [], 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, + 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: {}, + initialCustomizations: [], + source: repository.toString(), + revision: 'main', + workingDirectories: undefined, + discoveryDirectories: [undefined], + discoverySources: [repository.toString()], + 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); + 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: {} }, + 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); + agentHostService.enableRepositorySource(); + 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: {} }, + 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..6a2a2957dc8686 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostRepositoryConfig.test.ts @@ -0,0 +1,324 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { 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 { 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: { + branch: { type: 'string', title: 'Working branch' }, + mode: { type: 'string', title: 'Mode' }, + }, +}; + +suite('AgentHostRepositoryConfig', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + 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); + if (response instanceof Error) { + throw response; + } + assert.ok(response, 'unexpected configuration request'); + return response; + } + }(); + return { + calls, + connection, + capabilities, + setCapability(value: RepositorySourceCapability | undefined) { capabilities.repositorySource = value; }, + failRoot(error: Error) { root = error; }, + }; + } + + test('sends typed source and revision outside provider config', async () => { + const h = connectionWithResponses([ + { schema, values: { mode: 'plan', branch: 'feature', extra: 'host-default' } }, + ]); + const config = await resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, { branch: 'feature', mode: 'plan' }, CancellationToken.None, 'main'); + assert.deepStrictEqual({ calls: h.calls, config }, { + calls: [ + { provider: 'provider', repositorySource: repository, repositoryRevision: 'main', config: { branch: 'feature', mode: 'plan' } }, + ], + config: { mode: 'plan', branch: 'feature', extra: 'host-default' }, + }); + }); + + 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('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 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('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 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([error]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None), error); + }); + + 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('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 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 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: {} }]); + 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 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 ['', ' ']) { + test(`rejects an invalid explicit revision (${JSON.stringify(revision)})`, async () => { + const h = connectionWithResponses([]); + await assert.rejects(resolveAgentHostRepositoryConfig(h.connection, 'provider', repository, undefined, CancellationToken.None, revision), /nonempty string/); + 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 () => { + 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, []); + }); + + for (const source of [null, 17, '', ' ']) { + test(`rejects an invalid source in session state (${JSON.stringify(source)})`, () => { + 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({ repositoryRevision: 'main' }), /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, + repositorySource: withRepository ? repository.toString() : undefined, + config: { schema, values: {} }, + }); + } + + 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), 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), 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 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 () => { + 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('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); + }); + + 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); + }); +});