diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 103ccaf7cc697..392e09a42f3b3 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -10,6 +10,7 @@ import { URI } from '../../../base/common/uri.js'; import type { IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import type { IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; @@ -51,6 +52,7 @@ export class NullAgentHostService implements IAgentHostService { async disposeSession(_session: URI): Promise { } async createTerminal(_params: CreateTerminalParams): Promise { notSupported(); } async disposeTerminal(_terminal: URI): Promise { } + async invokeChangesetOperation(_params: InvokeChangesetOperationParams): Promise { return notSupported(); } async resourceList(_uri: URI): Promise { return notSupported(); } async resourceRead(_uri: URI): Promise { return notSupported(); } async resourceWrite(_params: ResourceWriteParams): Promise { return notSupported(); } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 11b3407acd134..48ba0ad688d04 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -31,6 +31,7 @@ import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes } from '../common/state/protocol/errors.js'; import { ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; @@ -825,6 +826,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC await this._sendRequest('disposeTerminal', { channel: terminal.toString() }); } + async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { + return await this._sendRequest('invokeChangesetOperation', params); + } + /** * List all sessions from the remote agent host. */ diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index b60a75a0367fb..3c71607488315 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -15,6 +15,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; import { ProtectedResourceMetadata, type ChangesetSummary, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; @@ -341,6 +342,26 @@ export const GITHUB_COPILOT_PROTECTED_RESOURCE: ProtectedResourceMetadata = { required: true, }; +/** + * Canonical {@link ProtectedResourceMetadata} for GitHub repository write + * operations (e.g. creating a pull request). Distinct from + * {@link GITHUB_COPILOT_PROTECTED_RESOURCE} so that the broader `repo` + * scope is only requested when a session actually needs it (e.g. when a + * changeset operation handler throws `AHP_AUTH_REQUIRED` with this + * resource), rather than at session create for every agent. + * + * `required: false` reflects that the resource is only needed on demand — + * agents do not have to advertise it eagerly. The workbench-side auth + * contributor resolves it lazily in response to operation invocations. + */ +export const GITHUB_REPO_PROTECTED_RESOURCE: ProtectedResourceMetadata = { + resource: 'https://api.github.com/repos', + resource_name: 'GitHub Repository', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['repo'], + required: false, +}; + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: ModelSelection; @@ -738,6 +759,15 @@ export interface IAgentService { */ authenticate(params: AuthenticateParams): Promise; + /** + * Returns the most recently pushed token for {@link resource}, or + * `undefined` when no token has been pushed (or after the entry was + * cleared). Used by service-level consumers (e.g. changeset operation + * handlers) that need bearer tokens for resources not tied to a + * specific agent — typically {@link GITHUB_REPO_PROTECTED_RESOURCE}. + */ + getAuthToken(resource: string): string | undefined; + /** List all available sessions from the Copilot CLI. */ listSessions(): Promise; @@ -780,6 +810,9 @@ export interface IAgentService { /** Dispose a terminal and kill its process if still running. */ disposeTerminal(terminal: URI): Promise; + /** Invoke a server-defined changeset operation. */ + invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise; + /** Gracefully shut down all sessions and the underlying client. */ shutdown(): Promise; @@ -922,6 +955,9 @@ export interface IAgentConnection { createTerminal(params: CreateTerminalParams): Promise; disposeTerminal(terminal: URI): Promise; + // ---- Changeset operations ----------------------------------------------- + invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise; + // ---- Filesystem operations ---------------------------------------------- resourceList(uri: URI): Promise; resourceRead(uri: URI): Promise; diff --git a/src/vs/platform/agentHost/common/changesetOperation.ts b/src/vs/platform/agentHost/common/changesetOperation.ts new file mode 100644 index 0000000000000..eb4501f60a4c2 --- /dev/null +++ b/src/vs/platform/agentHost/common/changesetOperation.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import type { IDisposable } from '../../../base/common/lifecycle.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; +import type { ChangesetOperation, ISessionGitState } from './state/sessionState.js'; + +/** + * Server-side handler for a changeset operation advertised via + * `changeset/operationsChanged`. + * + * The agent service validates the request shape (changeset exists, operation id + * known, target scope matches) before invoking the handler; the handler is only + * responsible for executing the operation. + */ +export interface IChangesetOperationHandler { + invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise; +} + +/** + * Context used by changeset operation contributions to decide which operations + * to advertise for a session changeset. + * + * Keep this interface intentionally small. Add new fields here only when a + * contribution genuinely needs them to compute operation availability. Likely + * future additions include the concrete changeset URI, the session state, the + * changeset state, or the working directory URI. + */ +export interface IChangesetOperationContext { + /** String form of the session URI that owns the changeset. */ + readonly sessionKey: string; + /** Current git metadata for the session. This is enough for the PR operations today. */ + readonly gitState: ISessionGitState; +} + +export interface IChangesetOperationRegistry { + registerChangesetOperationHandler(operationId: string, handler: IChangesetOperationHandler): IDisposable; + onDidChangeOperations(sessionKey: string): void; + refreshSessionGitState(sessionKey: string): Promise; +} + +export interface IChangesetOperationContribution extends IDisposable { + registerHandlers(registry: IChangesetOperationRegistry): IDisposable; + getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined; +} + +export interface IChangesetOperationContributionService extends IDisposable { + registerContribution(contribution: IChangesetOperationContribution): IDisposable; + getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined; + refreshOperationsFromCurrentState(sessionKey: string): void; + updateOperations(sessionKey: string, gitState: ISessionGitState): void; + invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise; +} diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index cf52cae65df66..80dd1bafee534 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -20,6 +20,7 @@ import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; @@ -223,6 +224,9 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos disposeTerminal(terminal: URI): Promise { return this._proxy.disposeTerminal(terminal); } + invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { + return this._proxy.invokeChangesetOperation(params); + } shutdown(): Promise { return this._proxy.shutdown(); } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationContributionService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationContributionService.ts new file mode 100644 index 0000000000000..599b0fb96dbf9 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationContributionService.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { buildSessionChangesetUri } from '../common/changesetUri.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import { ChangesetOperationScope, ChangesetOperationTargetKind, readSessionGitState, type ChangesetOperation, type ISessionGitState } from '../common/state/sessionState.js'; +import type { IChangesetOperationContribution, IChangesetOperationContributionService, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../common/changesetOperation.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; +import { AgentHostSessionGitStateService } from './agentHostSessionGitStateService.js'; + +export class AgentHostChangesetOperationContributionService extends Disposable implements IChangesetOperationContributionService { + + private readonly _contributions = new Set(); + private readonly _handlerRegistrations = this._register(new DisposableMap()); + private readonly _changesetOperationHandlers = new Map(); + private readonly _registry: IChangesetOperationRegistry; + + constructor( + private readonly _stateManager: AgentHostStateManager, + private readonly _sessionGitStateService: AgentHostSessionGitStateService, + ) { + super(); + this._registry = { + registerChangesetOperationHandler: (operationId, handler) => this._registerChangesetOperationHandler(operationId, handler), + onDidChangeOperations: sessionKey => this.refreshOperationsFromCurrentState(sessionKey), + refreshSessionGitState: sessionKey => this._refreshSessionGitStateAndOperations(sessionKey), + }; + } + + registerContribution(contribution: IChangesetOperationContribution): IDisposable { + if (this._contributions.has(contribution)) { + throw new Error('Changeset operation contribution already registered'); + } + this._contributions.add(contribution); + this._registerContributionHandlers(contribution); + return toDisposable(() => { + this._handlerRegistrations.deleteAndDispose(contribution); + this._contributions.delete(contribution); + contribution.dispose(); + }); + } + + getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined { + const operations: ChangesetOperation[] = []; + for (const contribution of this._contributions) { + const contributed = contribution.getOperations(context); + if (contributed) { + operations.push(...contributed); + } + } + return operations.length > 0 ? operations : undefined; + } + + refreshOperationsFromCurrentState(sessionKey: string): void { + const gitState = readSessionGitState(this._stateManager.getSessionState(sessionKey)?._meta); + if (!gitState) { + return; + } + this.updateOperations(sessionKey, gitState); + } + + updateOperations(sessionKey: string, gitState: ISessionGitState): void { + const branchUri = buildSessionChangesetUri(sessionKey); + const operations = this.getOperations({ sessionKey, gitState }); + this._stateManager.dispatchServerAction(branchUri, { + type: ActionType.ChangesetOperationsChanged, + operations: operations ? [...operations] : undefined, + }); + } + + private async _refreshSessionGitStateAndOperations(sessionKey: string): Promise { + const gitState = await this._sessionGitStateService.refreshSessionGitState(sessionKey); + if (gitState) { + this.updateOperations(sessionKey, gitState); + } + } + + async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { + const state = this._stateManager.getChangesetState(params.channel); + if (!state) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Changeset not found: ${params.channel}`); + } + const op = state.operations?.find(o => o.id === params.operationId); + if (!op) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unknown operation '${params.operationId}' on changeset ${params.channel}`); + } + const targetKind: ChangesetOperationScope = params.target?.kind === ChangesetOperationTargetKind.Resource + ? ChangesetOperationScope.Resource + : params.target?.kind === ChangesetOperationTargetKind.Range + ? ChangesetOperationScope.Range + : ChangesetOperationScope.Changeset; + if (!op.scopes.includes(targetKind)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Operation '${params.operationId}' does not support scope '${targetKind}' (allowed: ${op.scopes.join(', ')})`); + } + const handler = this._changesetOperationHandlers.get(params.operationId); + if (!handler) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `No operation handler registered for '${params.operationId}' on changeset ${params.channel}`); + } + return handler.invoke(params, CancellationToken.None); + } + + private _registerChangesetOperationHandler(operationId: string, handler: IChangesetOperationHandler): IDisposable { + if (this._changesetOperationHandlers.has(operationId)) { + throw new Error(`Changeset operation handler already registered for '${operationId}'`); + } + this._changesetOperationHandlers.set(operationId, handler); + return toDisposable(() => { + if (this._changesetOperationHandlers.get(operationId) === handler) { + this._changesetOperationHandlers.delete(operationId); + } + }); + } + + private _registerContributionHandlers(contribution: IChangesetOperationContribution): void { + if (this._handlerRegistrations.has(contribution)) { + return; + } + this._handlerRegistrations.set(contribution, contribution.registerHandlers(this._registry)); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts new file mode 100644 index 0000000000000..070cdadcb96ef --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; +import type { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import type { IChangesetOperationContributionService } from '../common/changesetOperation.js'; +import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; +import type { AgentHostStateManager } from './agentHostStateManager.js'; + +export function registerDefaultChangesetOperationContributions( + service: IChangesetOperationContributionService, + instantiationService: IInstantiationService, + stateManager: AgentHostStateManager, +): IDisposable { + const store = new DisposableStore(); + store.add(service.registerContribution( + instantiationService.createInstance(AgentHostPullRequestOperationContribution, stateManager) + )); + return store; +} diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 4ea7d0d900b2e..fc3d0bebb91c5 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -45,6 +45,27 @@ export interface IAgentHostGitService { * worktree that still contains uncommitted work. */ hasUncommittedChanges(workingDirectory: URI): Promise; + + /** + * Stages and commits all tracked, staged, and untracked changes in the + * working tree. Mirrors the Copilot CLI session PR path, which commits + * uncommitted work before creating a pull request. + */ + commitAll(workingDirectory: URI, message: string): Promise; + + /** + * Returns true when the named branch has an upstream tracking ref + * (i.e. `@{upstream}` resolves). Used before {@link pushBranch} + * to decide whether `--set-upstream` is needed. + */ + hasUpstream(workingDirectory: URI, branchName: string): Promise; + + /** + * Pushes {@link branchName} to `origin`. When {@link setUpstream} is + * true, the push uses `--set-upstream` so subsequent fetch/push + * commands track the remote branch. + */ + pushBranch(workingDirectory: URI, branchName: string, setUpstream: boolean): Promise; /** * Computes the {@link ISessionGitState} for the working directory by * shelling out to `git`. Returns undefined if the directory is not a @@ -273,6 +294,25 @@ export class AgentHostGitService implements IAgentHostGitService { return !!output && output.trim().length > 0; } + async commitAll(workingDirectory: URI, message: string): Promise { + await this._runGit(workingDirectory, ['add', '-A', '--', ':/'], { throwOnError: true }); + await this._runGit(workingDirectory, ['commit', '--no-verify', '--no-gpg-sign', '-m', message], { timeout: 60_000, throwOnError: true }); + } + + async hasUpstream(workingDirectory: URI, branchName: string): Promise { + const output = await this._runGit(workingDirectory, ['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]); + return output !== undefined && output.trim().length > 0; + } + + async pushBranch(workingDirectory: URI, branchName: string, setUpstream: boolean): Promise { + const args = ['push']; + if (setUpstream) { + args.push('--set-upstream'); + } + args.push('origin', branchName); + await this._runGit(workingDirectory, args, { timeout: 60_000, throwOnError: true }); + } + async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise { // Bail fast if not inside a git work tree so callers can fall back // to other diff sources. diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index bab96278c87fb..972f718627a30 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -15,7 +15,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostClaudeSdkPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; +import { AgentHostClaudeSdkPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService } from '../common/agentService.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; @@ -153,6 +153,7 @@ async function startAgentHost(): Promise { const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService); + diServices.set(IAgentService, agentService); const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); diServices.set(IAgentPluginManager, pluginManager); const diffComputeService = disposables.add(new NodeWorkerDiffComputeService(logService)); diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts new file mode 100644 index 0000000000000..746074a8d5f78 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; +import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; +import { readSessionGitState, type ChangesetOperationFollowUp, type SessionState } from '../common/state/sessionState.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgentHostGitService } from './agentHostGitService.js'; +import { type IChangesetOperationHandler } from '../common/changesetOperation.js'; +import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; + +export interface PullRequestCreatedEvent { + readonly sessionKey: string; + readonly branchName: string; +} + +/** + * Server-side handler for the `create-pr` and `create-draft-pr` changeset + * operations advertised on git-backed sessions whose working directory has + * a GitHub remote. Operation availability is recomputed by + * `AgentHostChangesetOperationContributionService.updateOperations`. + * + * The flow mirrors the Copilot CLI extension's `createPullRequest` helper + * (`extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts`): + * + * 1. Resolve session → working directory + current/base branch from + * {@link ISessionGitState}. + * 2. Commit any uncommitted working-tree changes. + * 3. Push the current branch to `origin` (with `--set-upstream` when missing). + * 4. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner} + * / {@link ISessionGitState.githubRepo} (populated by the git probe). + * 5. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls` + * via {@link IAgentHostOctoKitService}. + * 6. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}. + */ +export class AgentHostPullRequestOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_CREATE_PR = 'create-pr'; + public static readonly OPERATION_CREATE_DRAFT_PR = 'create-draft-pr'; + + constructor( + private readonly _draft: boolean, + private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void, + @IAgentService private readonly _agentService: IAgentService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, + @ILogService private readonly _logService: ILogService, + ) { } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const abortController = new AbortController(); + if (token.isCancellationRequested) { + abortController.abort(); + } + const cancellationListener = token.onCancellationRequested(() => abortController.abort()); + try { + return await this._invoke(params, token, abortController.signal); + } finally { + cancellationListener.dispose(); + } + } + + private async _invoke(params: InvokeChangesetOperationParams, token: CancellationToken, signal: AbortSignal): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`); + } + this._throwIfCancelled(token); + const sessionUri = parsed.sessionUri; + + const sessionState = this._getSessionState(sessionUri); + if (!sessionState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); + } + + const workingDirectoryStr = sessionState.summary.workingDirectory; + if (!workingDirectoryStr) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); + } + const workingDirectory = URI.parse(workingDirectoryStr); + + const gitState = readSessionGitState(sessionState._meta); + if (!gitState?.hasGitHubRemote || !gitState.githubOwner || !gitState.githubRepo) { + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + `Session's working directory is not a GitHub-backed git repo: ${sessionUri}`, + ); + } + + const branchName = gitState.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); + if (!branchName) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`); + } + + const baseBranchName = gitState.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); + if (!baseBranchName) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`); + } + // `getDefaultBranch` may return `origin/` — `pulls` API wants the bare name. + const base = baseBranchName.startsWith('origin/') ? baseBranchName.substring('origin/'.length) : baseBranchName; + + const authToken = this._agentService.getAuthToken(GITHUB_REPO_PROTECTED_RESOURCE.resource); + if (!authToken) { + throw new ProtocolError( + AHP_AUTH_REQUIRED, + localize('agentHost.changeset.pr.authRequired', "Sign in to GitHub with repository access to create a pull request."), + [GITHUB_REPO_PROTECTED_RESOURCE], + ); + } + + const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory); + if (hasUncommitted) { + this._throwIfCancelled(token); + this._logService.info(`[AgentHostPullRequestOperationHandler] Committing uncommitted changes for session ${sessionUri}`); + try { + await this._gitService.commitAll(workingDirectory, this._formatCommitMessage(branchName)); + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to commit changes before creating a pull request: ${err instanceof Error ? err.message : String(err)}`); + } + } + this._throwIfCancelled(token); + + const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: base }); + if (branchChanges === undefined) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.computeChangesFailed', "Could not compute branch changes to create a pull request.")); + } + if (branchChanges !== undefined && branchChanges.length === 0) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.noChanges', "There are no branch changes to create a pull request for.")); + } + this._throwIfCancelled(token); + + this._logService.info(`[AgentHostPullRequestOperationHandler] Pushing branch ${branchName} for session ${sessionUri}`); + const upstreamPresent = await this._gitService.hasUpstream(workingDirectory, branchName); + this._throwIfCancelled(token); + try { + await this._gitService.pushBranch(workingDirectory, branchName, !upstreamPresent); + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to push branch '${branchName}': ${err instanceof Error ? err.message : String(err)}`); + } + this._throwIfCancelled(token); + + const title = this._formatTitle(branchName); + const body = this._formatBody(branchName, base); + + const existing = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + if (existing) { + this._throwIfCancelled(token); + this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + return this._createResult(existing, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", existing.number, existing.url)); + } + this._throwIfCancelled(token); + + this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitState.githubOwner}/${gitState.githubRepo} ${branchName} -> ${base}`); + let created: { readonly url: string; readonly number: number }; + try { + created = await this._octoKitService.createPullRequest( + gitState.githubOwner, + gitState.githubRepo, + title, + body, + branchName, + base, + this._draft, + authToken, + signal, + ); + } catch (err) { + this._throwIfCancelled(token); + let foundAfterFailure: { readonly url: string; readonly number: number } | undefined; + try { + foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + } catch { + this._throwIfCancelled(token); + throw err; + } + if (foundAfterFailure) { + this._throwIfCancelled(token); + this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + return this._createResult(foundAfterFailure, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", foundAfterFailure.number, foundAfterFailure.url)); + } + throw err; + } + this._throwIfCancelled(token); + const message = this._draft + ? localize('agentHost.changeset.pr.createdDraft', "Created draft pull request [#{0}]({1}).", created.number, created.url) + : localize('agentHost.changeset.pr.created', "Created pull request [#{0}]({1}).", created.number, created.url); + + this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + return this._createResult(created, message); + } + + private _throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.cancelled', "Pull request operation was cancelled.")); + } + } + + private _formatTitle(branchName: string): string { + // Beautify a branch name like `feat/foo-bar` into `feat: foo bar`. + const idx = branchName.indexOf('/'); + if (idx > 0 && idx < branchName.length - 1) { + const prefix = branchName.substring(0, idx); + const rest = branchName.substring(idx + 1).replace(/[-_]+/g, ' '); + return `${prefix}: ${rest}`; + } + return branchName.replace(/[-_]+/g, ' '); + } + + private _formatCommitMessage(branchName: string): string { + return localize('agentHost.changeset.pr.commitMessage', "Agent Host changes for {0}", branchName); + } + + private _formatBody(branchName: string, baseBranchName: string): string { + return localize('agentHost.changeset.pr.body', "Created from `{0}` targeting `{1}`.", branchName, baseBranchName); + } + + private _createResult(created: { readonly url: string; readonly number: number }, message: string): InvokeChangesetOperationResult { + const followUp: ChangesetOperationFollowUp = { + content: { uri: created.url, contentType: 'text/html' }, + external: true, + }; + return { message: { markdown: message }, followUp }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts new file mode 100644 index 0000000000000..4cddca845ef66 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/changesetOperation.js'; +import { ChangesetOperationScope, type ChangesetOperation } from '../common/state/sessionState.js'; +import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } from './agentHostPullRequestOperationHandler.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +const OPTIMISTIC_PR_CREATED_CACHE_TTL = 30_000; + +/** + * Owns PR-specific changeset operation availability. + * + * The optimistic cache is intentionally in-memory only. It hides Create PR + * immediately after a successful create/reuse while the normal git/session + * refresh catches up; persisted PR metadata remains out of scope. + */ +export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { + + private readonly _optimisticCreatedPullRequests = this._register(new DisposableMap()); + private _registry: IChangesetOperationRegistry | undefined; + + readonly onPullRequestCreated = (event: PullRequestCreatedEvent): void => { + const key = this._key(event.sessionKey, event.branchName); + this._optimisticCreatedPullRequests.set(key, disposableTimeout(() => { + this._optimisticCreatedPullRequests.deleteAndDispose(key); + this._registry?.onDidChangeOperations(event.sessionKey); + }, OPTIMISTIC_PR_CREATED_CACHE_TTL)); + + this._registry?.onDidChangeOperations(event.sessionKey); + this._registry?.refreshSessionGitState(event.sessionKey).finally(() => { + if (this._optimisticCreatedPullRequests.has(key)) { + this._optimisticCreatedPullRequests.deleteAndDispose(key); + this._registry?.onDidChangeOperations(event.sessionKey); + } + }); + }; + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + registerHandlers(registry: IChangesetOperationRegistry): IDisposable { + this._registry = registry; + const store = new DisposableStore(); + const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); + const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, getSessionState, this.onPullRequestCreated.bind(this)); + const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, getSessionState, this.onPullRequestCreated.bind(this)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); + store.add({ dispose: () => { this._registry = undefined; } }); + return store; + } + + getOperations({ sessionKey, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + if (gitState.branchName && this._optimisticCreatedPullRequests.has(this._key(sessionKey, gitState.branchName))) { + return undefined; + } + + const hasChanges = (gitState.outgoingChanges ?? 0) > 0 || (gitState.uncommittedChanges ?? 0) > 0; + if (!gitState.hasGitHubRemote || !hasChanges) { + return undefined; + } + + return [ + { + id: 'create-pr', + label: localize('agentHost.changeset.createPR', "Create Pull Request"), + scopes: [ChangesetOperationScope.Changeset], + icon: 'git-pull-request', + }, + { + id: 'create-draft-pr', + label: localize('agentHost.changeset.createDraftPR', "Create Draft Pull Request"), + scopes: [ChangesetOperationScope.Changeset], + icon: 'git-pull-request-draft', + }, + ]; + } + + private _key(sessionKey: string, branchName: string): string { + return `${sessionKey}\n${branchName}`; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 3da5234e18507..332cde8857982 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -40,7 +40,7 @@ import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxySer import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentService } from './agentService.js'; -import { AgentHostClaudeSdkPathEnvVar } from '../common/agentService.js'; +import { AgentHostClaudeSdkPathEnvVar, IAgentService } from '../common/agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -218,6 +218,7 @@ async function main(): Promise { // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService); disposables.add(agentService); + diServices.set(IAgentService, agentService); // Register agents if (!options.quiet) { diff --git a/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts b/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts new file mode 100644 index 0000000000000..ce1485b7ebec9 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { equals as objectEquals } from '../../../base/common/objects.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { buildSessionChangesetUri, buildUncommittedChangesetUri, formatSessionChangesetDescription } from '../common/changesetUri.js'; +import { readSessionGitState, withSessionGitState, type ISessionGitState } from '../common/state/sessionState.js'; +import { IAgentHostGitService } from './agentHostGitService.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +export class AgentHostSessionGitStateService extends Disposable { + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + /** + * Fire-and-forget friendly probe used during normal session lifecycle. + * Returns `undefined` when there is no git state change to publish. + */ + async attachGitState(session: URI, workingDirectory: URI | undefined): Promise { + if (!workingDirectory) { + return undefined; + } + const sessionKey = session.toString(); + try { + const gitState = await this._gitService.getSessionGitState(workingDirectory); + if (!gitState) { + this._stripGitOnlyChangesetEntries(sessionKey); + return undefined; + } + const current = this._stateManager.getSessionState(sessionKey)?._meta; + if (objectEquals(readSessionGitState(current), gitState)) { + return undefined; + } + this._setSessionGitState(sessionKey, gitState); + return gitState; + } catch (e) { + this._logService.warn(`[AgentHostSessionGitStateService] Failed to compute git state for ${session}`, e); + return undefined; + } + } + + async refreshSessionGitState(sessionKey: string): Promise { + const workingDirectoryStr = this._stateManager.getSessionState(sessionKey)?.summary.workingDirectory; + if (!workingDirectoryStr) { + return undefined; + } + const gitState = await this._gitService.getSessionGitState(URI.parse(workingDirectoryStr)); + if (!gitState) { + this._stripGitOnlyChangesetEntries(sessionKey); + return undefined; + } + this._setSessionGitState(sessionKey, gitState); + return gitState; + } + + private _setSessionGitState(sessionKey: string, gitState: ISessionGitState): void { + const current = this._stateManager.getSessionState(sessionKey)?._meta; + this._stateManager.setSessionMeta(sessionKey, withSessionGitState(current, gitState)); + this._updateBranchChangesetDescription(sessionKey, gitState); + } + + private _stripGitOnlyChangesetEntries(sessionKey: string): void { + const state = this._stateManager.getSessionState(sessionKey); + const current = state?.summary.changesets; + if (!current || current.length === 0) { + return; + } + const branchUri = buildSessionChangesetUri(sessionKey); + const uncommittedUri = buildUncommittedChangesetUri(sessionKey); + const filtered = current.filter(c => c.uriTemplate !== branchUri && c.uriTemplate !== uncommittedUri); + if (filtered.length === current.length) { + return; + } + this._stateManager.setSessionChangesets(sessionKey, filtered); + } + + private _updateBranchChangesetDescription(sessionKey: string, gitState: { branchName?: string; baseBranchName?: string }): void { + const description = formatSessionChangesetDescription(gitState.branchName, gitState.baseBranchName); + const state = this._stateManager.getSessionState(sessionKey); + const current = state?.summary.changesets; + if (!current || current.length === 0) { + return; + } + const branchUri = buildSessionChangesetUri(sessionKey); + let changed = false; + const next = current.map(c => { + if (c.uriTemplate !== branchUri) { + return c; + } + if (c.description === description) { + return c; + } + changed = true; + if (description === undefined) { + const { description: _omit, ...rest } = c; + return rest; + } + return { ...c, description }; + }); + if (!changed) { + return; + } + this._stateManager.setSessionChangesets(sessionKey, next); + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c8cacadf381e2..4ec06190cb131 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -10,7 +10,6 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableResourceMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js'; -import { equals as objectEquals } from '../../../base/common/objects.js'; import { observableValue } from '../../../base/common/observable.js'; import { extname as resourcesExtname, isEqual, joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; @@ -19,15 +18,16 @@ import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCod import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; +import { AgentProvider, AgentSession, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; -import { buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri, formatSessionChangesetDescription, parseChangesetUri } from '../common/changesetUri.js'; +import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { SessionPendingMessageSetAction, SessionTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUriPrefix, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUriPrefix, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -43,7 +43,11 @@ import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompleti import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; import { AgentHostSkillCompletionProvider } from './agentHostSkillCompletionProvider.js'; import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; +import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; +import { AgentHostChangesetOperationContributionService } from './agentHostChangesetOperationContributionService.js'; +import { registerDefaultChangesetOperationContributions } from './agentHostChangesetOperationContributions.js'; +import { AgentHostSessionGitStateService } from './agentHostSessionGitStateService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; @@ -83,6 +87,13 @@ export class AgentService extends Disposable implements IAgentService { /** Registered providers keyed by their {@link AgentProvider} id. */ private readonly _providers = new Map(); + /** + * Most-recently pushed bearer token for each {@link AuthenticateParams.resource}. + * Mirrors what is fanned out to per-agent `authenticate(...)` so service-level + * consumers (e.g. changeset operation handlers) can read tokens for resources + * not tied to a specific agent. + */ + private readonly _authTokens = new Map(); /** Maps each active session URI (toString) to its owning provider. */ private readonly _sessionToProvider = new Map(); /** Subscriptions to provider progress events; cleared when providers change. */ @@ -97,6 +108,10 @@ export class AgentService extends Disposable implements IAgentService { private readonly _changesets: IAgentHostChangesetService; /** Owns AgentService-side orchestration of the changeset feature. */ private readonly _changesetCoordinator: ChangesetSessionCoordinator; + /** Owns session git-state probing and git-backed catalogue decoration. */ + private readonly _sessionGitStateService: AgentHostSessionGitStateService; + /** Owns changeset operation contributions and handler activation. */ + private readonly _changesetOperationContributionService: AgentHostChangesetOperationContributionService; /** Manages PTY-backed terminals for the agent host protocol. */ private readonly _terminalManager: AgentHostTerminalManager; private readonly _configurationService: IAgentConfigurationService; @@ -166,6 +181,7 @@ export class AgentService extends Disposable implements IAgentService { updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values); const services = new ServiceCollection( [ILogService, this._logService], + [IAgentService, this], [IProductService, this._productService], [IAgentConfigurationService, configurationService], [IAgentHostGitService, this._gitService], @@ -177,6 +193,11 @@ export class AgentService extends Disposable implements IAgentService { [ISessionDataService, this._sessionDataService], ); const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + const agentHostOctoKitService = instantiationService.createInstance(AgentHostOctoKitService, undefined); + services.set(IAgentHostOctoKitService, agentHostOctoKitService); + this._sessionGitStateService = this._register(instantiationService.createInstance(AgentHostSessionGitStateService, this._stateManager)); + this._changesetOperationContributionService = this._register(instantiationService.createInstance(AgentHostChangesetOperationContributionService, this._stateManager, this._sessionGitStateService)); + this._register(registerDefaultChangesetOperationContributions(this._changesetOperationContributionService, instantiationService, this._stateManager)); // The checkpoint service is constructed in the outer agent-host // DI scope and passed via {@link _checkpointService}; register it @@ -287,9 +308,28 @@ export class AgentService extends Disposable implements IAgentService { ); } } + // GITHUB_REPO_PROTECTED_RESOURCE is not claimed by an agent. It is + // accepted on demand so service-level consumers (changeset operation + // handlers, etc.) can read the token later. + if (matching.length === 0 && params.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { + authenticated = true; + } + if (authenticated) { + this._authTokens.set(params.resource, params.token); + } return { authenticated }; } + getAuthToken(resource: string): string | undefined { + return this._authTokens.get(resource); + } + + // ---- Changeset operation handlers -------------------------------------- + + async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { + return this._changesetOperationContributionService.invokeChangesetOperation(params); + } + // ---- session management ------------------------------------------------- async listSessions(): Promise { @@ -599,93 +639,16 @@ export class AgentService extends Disposable implements IAgentService { * rendering naturally skips them in the meantime. */ private _attachGitState(session: URI, workingDirectory: URI | undefined): void { - if (!workingDirectory) { - return; - } const sessionKey = session.toString(); - this._gitService.getSessionGitState(workingDirectory).then( + this._sessionGitStateService.attachGitState(session, workingDirectory).then( gitState => { - if (!gitState) { - this._stripGitOnlyChangesetEntries(sessionKey); - return; + if (gitState) { + this._changesetOperationContributionService.updateOperations(sessionKey, gitState); } - const current = this._stateManager.getSessionState(sessionKey)?._meta; - // Skip the action if the computed git state hasn't changed; this is - // called after every turn, so deduping avoids needless action churn. - if (objectEquals(readSessionGitState(current), gitState)) { - return; - } - const next = withSessionGitState(current, gitState); - this._stateManager.setSessionMeta(sessionKey, next); - this._updateBranchChangesetDescription(sessionKey, gitState); - }, - e => { - this._logService.warn(`[AgentService] Failed to compute git state for ${session}`, e); }, - ); - } - - /** - * Drops the `Branch Changes` and `Uncommitted Changes` entries from - * the session's catalogue. Called only when the git probe has - * definitively determined the working directory is not a git repo. - * An absent / unresolved working directory is treated as transient - * and does NOT trigger a strip — see {@link _attachGitState}. - * Backing per-changeset states (registered unconditionally) are left - * in place — only the catalogue advertisements are stripped. - */ - private _stripGitOnlyChangesetEntries(sessionKey: string): void { - const state = this._stateManager.getSessionState(sessionKey); - const current = state?.summary.changesets; - if (!current || current.length === 0) { - return; - } - const branchUri = buildSessionChangesetUri(sessionKey); - const uncommittedUri = buildUncommittedChangesetUri(sessionKey); - const filtered = current.filter(c => c.uriTemplate !== branchUri && c.uriTemplate !== uncommittedUri); - if (filtered.length === current.length) { - return; - } - this._stateManager.setSessionChangesets(sessionKey, filtered); - } - - /** - * Patches the `Branch Changes` catalogue entry's `description` to - * `${branchName} → ${baseBranchName}` once the git probe resolves - * both names (typical worktree-isolation case). No-ops when the - * entry has already been stripped (non-git working dir), when the - * branch info is incomplete, or when the description is unchanged. - * The count-refresh path in `AgentHostChangesetService` preserves - * extra fields via spread, so the description survives subsequent - * compute passes without further plumbing. - */ - private _updateBranchChangesetDescription(sessionKey: string, gitState: { branchName?: string; baseBranchName?: string }): void { - const description = formatSessionChangesetDescription(gitState.branchName, gitState.baseBranchName); - const state = this._stateManager.getSessionState(sessionKey); - const current = state?.summary.changesets; - if (!current || current.length === 0) { - return; - } - const branchUri = buildSessionChangesetUri(sessionKey); - let changed = false; - const next = current.map(c => { - if (c.uriTemplate !== branchUri) { - return c; - } - if (c.description === description) { - return c; - } - changed = true; - if (description === undefined) { - const { description: _omit, ...rest } = c; - return rest; - } - return { ...c, description }; + ).catch(e => { + this._logService.warn(`[AgentService] Failed to update changeset operations after git state attach for ${sessionKey}`, e); }); - if (!changed) { - return; - } - this._stateManager.setSessionChangesets(sessionKey, next); } private _persistConfigValues(session: URI, values: Record): void { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 39b11ed23bba1..cb297e71471ca 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -34,7 +34,7 @@ import { type ReconnectParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; -import { ChangesetOperationScope, ChangesetOperationTargetKind, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js'; +import { isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { @@ -867,30 +867,7 @@ export class ProtocolServerHandler extends Disposable { return null; }, invokeChangesetOperation: async (_client, params) => { - // v1 wires the request/response infrastructure but does not - // register any concrete operation handlers. The body validates - // the request shape against the current changeset state so that - // future producers slotting in handlers don't need to repeat - // boilerplate, then rejects the request with a JSON-RPC error - // for the "no handler" case. See the Changesets spec section - // "Changeset Operations" for the contract. - const state = this._stateManager.getChangesetState(params.channel); - if (!state) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Changeset not found: ${params.channel}`); - } - const op = state.operations?.find(o => o.id === params.operationId); - if (!op) { - throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unknown operation '${params.operationId}' on changeset ${params.channel}`); - } - const targetKind: ChangesetOperationScope = params.target?.kind === ChangesetOperationTargetKind.Resource - ? ChangesetOperationScope.Resource - : params.target?.kind === ChangesetOperationTargetKind.Range - ? ChangesetOperationScope.Range - : ChangesetOperationScope.Changeset; - if (!op.scopes.includes(targetKind)) { - throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Operation '${params.operationId}' does not support scope '${targetKind}' (allowed: ${op.scopes.join(', ')})`); - } - throw new ProtocolError(JsonRpcErrorCodes.InternalError, `No operation handler registered for '${params.operationId}' on changeset ${params.channel}`); + return this._agentService.invokeChangesetOperation(params); }, }; diff --git a/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts new file mode 100644 index 0000000000000..61a535012217c --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; + +export type FetchFunction = typeof globalThis.fetch; + +/** + * Successful result of {@link IAgentHostOctoKitService.createPullRequest}. + * + * Mirrors the `CreatedPullRequest` type returned by `OctoKitService` in + * `extensions/copilot/src/platform/github/common/githubService.ts` so the + * shapes line up if/when the two are ported together. + */ +export interface CreatedPullRequest { + readonly url: string; + readonly number: number; +} + +interface GitHubPullRequestResponseItem { + readonly html_url?: unknown; + readonly number?: unknown; +} + +/** + * Minimal GitHub REST client living in the agent-host process. + * + * The agent host runs headless and has no access to the workbench + * `IOctoKitService` / Octokit / VS Code auth providers. This service is a + * deliberately small re-implementation of the bits we need, modelled on + * `OctoKitService` from the Copilot extension so the API surface is + * familiar. Only operations the agent host actually needs are exposed — + * extend this interface as new changeset operations are added. + * + * The caller is responsible for supplying a GitHub OAuth token with the + * scopes required by the operation (e.g. `repo` for {@link createPullRequest}). + * Tokens are typically obtained from the agent host's + * `authenticate(resource, token)` token store, which the workbench pushes + * on session create via the same channel used for `ICopilotApiService`. + */ +export interface IAgentHostOctoKitService { + readonly _serviceBrand: undefined; + + /** + * Creates a pull request on github.com. + * + * Mirrors `OctoKitService.createPullRequest` from the Copilot extension. + * Throws on non-2xx responses or malformed payloads. + */ + createPullRequest( + owner: string, + repo: string, + title: string, + body: string, + head: string, + base: string, + draft: boolean, + token: string, + signal: AbortSignal, + ): Promise; + + /** Finds the most recently updated pull request for `owner:branch`, if any. */ + findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise; +} + +export const IAgentHostOctoKitService = createDecorator('agentHostOctoKitService'); + +const GITHUB_API_HOST = 'https://api.github.com'; +const GITHUB_API_VERSION = '2022-11-28'; +const MAX_ERROR_RESPONSE_BODY_LENGTH = 500; + +export class AgentHostOctoKitService implements IAgentHostOctoKitService { + + declare readonly _serviceBrand: undefined; + + private readonly _fetch: FetchFunction; + + constructor( + fetchFn: FetchFunction | undefined, + @ILogService private readonly _logService: ILogService, + ) { + this._fetch = fetchFn ?? globalThis.fetch; + } + + async createPullRequest( + owner: string, + repo: string, + title: string, + body: string, + head: string, + base: string, + draft: boolean, + token: string, + signal: AbortSignal, + ): Promise { + const response = await this._makeGHAPIRequest( + `repos/${owner}/${repo}/pulls`, + 'POST', + token, + signal, + { title, body, head, base, draft }, + ); + + const html_url = (response as { html_url?: unknown } | undefined)?.html_url; + const number = (response as { number?: unknown } | undefined)?.number; + if (typeof html_url !== 'string' || typeof number !== 'number') { + throw new Error(`Failed to create pull request for ${owner}/${repo}`); + } + + return { url: html_url, number }; + } + + async findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise { + const response = await this._makeGHAPIRequest( + `repos/${owner}/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`, + 'GET', + token, + signal, + ); + if (!Array.isArray(response) || response.length === 0) { + return undefined; + } + const first = response[0] as GitHubPullRequestResponseItem | undefined; + const html_url = first?.html_url; + const number = first?.number; + return typeof html_url === 'string' && typeof number === 'number' + ? { url: html_url, number } + : undefined; + } + + private async _makeGHAPIRequest( + routeSlug: string, + method: 'GET' | 'POST', + token: string, + signal: AbortSignal, + body?: Record, + ): Promise { + const url = `${GITHUB_API_HOST}/${routeSlug}`; + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; + if (body) { + headers['Content-Type'] = 'application/json'; + } + + let response: Response; + try { + response = await this._fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal, + }); + } catch (err) { + if (signal.aborted) { + throw err; + } + this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Network error`, err); + throw err; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => undefined); + const errorDetail = this._formatErrorResponseBody(errorText); + this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Status: ${response.status}${errorDetail ? ` - ${errorDetail}` : ''}`); + throw new Error(`GitHub API request failed: ${method} ${routeSlug} - ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); + } + + try { + return await response.json(); + } catch (err) { + this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Failed to parse JSON`, err); + throw err; + } + } + + private _formatErrorResponseBody(errorText: string | undefined): string | undefined { + const normalized = errorText?.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return undefined; + } + return normalized.length > MAX_ERROR_RESPONSE_BODY_LENGTH + ? `${normalized.substring(0, MAX_ERROR_RESPONSE_BODY_LENGTH)}...` + : normalized; + } +} diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 10fc8e93ba9c7..4a026f5495aae 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -185,6 +185,9 @@ export function createNoopGitService(): import('../../node/agentHostGitService.j removeWorktree: async () => { }, branchExists: async () => false, hasUncommittedChanges: async () => false, + commitAll: async () => { }, + hasUpstream: async () => false, + pushBranch: async () => { }, getSessionGitState: async () => undefined, computeSessionFileDiffs: async () => undefined, showBlob: async () => undefined, diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts new file mode 100644 index 0000000000000..23ee370560810 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -0,0 +1,270 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js'; +import { buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { withSessionGitState, type ISessionFileDiff, SessionStatus } from '../../common/state/sessionState.js'; +import type { IAgentHostGitService } from '../../node/agentHostGitService.js'; +import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import type { CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; + +class TestGitService implements IAgentHostGitService { + declare readonly _serviceBrand: undefined; + + readonly calls: string[] = []; + uncommitted = false; + upstream = false; + branchChanges: readonly ISessionFileDiff[] | undefined = [{ after: { uri: 'file:///repo/file.ts', content: { uri: 'file:///repo/file.ts' } } }]; + + async isInsideWorkTree(): Promise { return true; } + async getCurrentBranch(): Promise { return 'feature/test'; } + async getDefaultBranch(): Promise { return 'main'; } + async getBranches(): Promise { return []; } + async getRepositoryRoot(): Promise { return URI.file('/repo'); } + async getWorktreeRoots(): Promise { return []; } + async addWorktree(): Promise { } + async addExistingWorktree(): Promise { } + async removeWorktree(): Promise { } + async branchExists(): Promise { return false; } + async hasUncommittedChanges(): Promise { + this.calls.push('hasUncommittedChanges'); + return this.uncommitted; + } + async commitAll(_workingDirectory: URI, message: string): Promise { + this.calls.push(`commitAll:${message}`); + this.uncommitted = false; + } + async hasUpstream(): Promise { + this.calls.push('hasUpstream'); + return this.upstream; + } + async pushBranch(_workingDirectory: URI, branchName: string, setUpstream: boolean): Promise { + this.calls.push(`pushBranch:${branchName}:${setUpstream}`); + } + async getSessionGitState(): Promise { return undefined; } + async computeSessionFileDiffs(): Promise { + this.calls.push('computeSessionFileDiffs'); + return this.branchChanges; + } + async showBlob(): Promise { return undefined; } + async captureWorkingTreeAsTree(): Promise { return undefined; } + async commitTree(): Promise { return undefined; } + async updateRef(): Promise { } + async deleteRefs(): Promise { } + async revParse(): Promise { return undefined; } + async computeFileDiffsBetweenRefs(): Promise { return undefined; } +} + +class TestOctoKitService implements IAgentHostOctoKitService { + declare readonly _serviceBrand: undefined; + + readonly calls: string[] = []; + existing: CreatedPullRequest | undefined; + existingAfterCreateFailure: CreatedPullRequest | undefined; + createError: Error | undefined; + findAfterCreateError: Error | undefined; + created: CreatedPullRequest = { url: 'https://github.com/microsoft/vscode/pull/123', number: 123 }; + + async createPullRequest(_owner: string, _repo: string, _title: string, _body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { + this.calls.push(`createPullRequest:${draft}`); + if (this.createError) { + throw this.createError; + } + return this.created; + } + async findPullRequestByHeadBranch(_owner: string, _repo: string, branch: string, _token: string, _signal: AbortSignal): Promise { + this.calls.push(`findPullRequestByHeadBranch:${branch}`); + if (this.calls.some(call => call.startsWith('createPullRequest:'))) { + if (this.findAfterCreateError) { + throw this.findAfterCreateError; + } + return this.existingAfterCreateFailure; + } + return this.existing; + } +} + +function createAgentService(): IAgentService { + return { + getAuthToken: resource => resource === GITHUB_REPO_PROTECTED_RESOURCE.resource ? 'gh-token' : undefined, + } as IAgentService; +} + +function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[] } { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = URI.parse('agent:/session'); + const createdEvents: string[] = []; + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: 1, + modifiedAt: 1, + workingDirectory: URI.file('/repo').toString(), + }); + stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, { + hasGitHubRemote: true, + githubOwner: 'microsoft', + githubRepo: 'vscode', + branchName: 'feature/test', + baseBranchName: 'main', + })); + return { + handler: new AgentHostPullRequestOperationHandler(false, sessionKey => stateManager.getSessionState(sessionKey), event => createdEvents.push(`${event.sessionKey}:${event.branchName}`), createAgentService(), gitService, octoKitService, new NullLogService()), + session, + createdEvents, + }; +} + +suite('AgentHostPullRequestOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + // Matches the Copilot CLI Agent Window behavior: if the session has + // uncommitted work, Create PR first commits that work, then pushes the + // branch, then asks GitHub to create the PR. + test('commits uncommitted changes before pushing and creating a pull request', async () => { + const gitService = new TestGitService(); + gitService.uncommitted = true; + const octoKitService = new TestOctoKitService(); + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + gitCalls: gitService.calls, + octoCalls: octoKitService.calls, + createdEvents, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123).' }, + gitCalls: [ + 'hasUncommittedChanges', + 'commitAll:Agent Host changes for feature/test', + 'computeSessionFileDiffs', + 'hasUpstream', + 'pushBranch:feature/test:true', + ], + octoCalls: [ + 'findPullRequestByHeadBranch:feature/test', + 'createPullRequest:false', + ], + createdEvents: ['agent:/session:feature/test'], + }); + }); + + // GitHub returns 422 when a PR already exists for the branch. The handler + // should preflight the branch and return/open the existing PR instead of + // trying to create a duplicate. + test('returns an existing pull request without creating a duplicate', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.existing = { url: 'https://github.com/microsoft/vscode/pull/7', number: 7 }; + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + octoCalls: octoKitService.calls, + followUp: result.followUp, + createdEvents, + }, { + message: { markdown: 'Pull request [#7](https://github.com/microsoft/vscode/pull/7) already exists.' }, + octoCalls: ['findPullRequestByHeadBranch:feature/test'], + followUp: { content: { uri: 'https://github.com/microsoft/vscode/pull/7', contentType: 'text/html' }, external: true }, + createdEvents: ['agent:/session:feature/test'], + }); + }); + + // A visible PR button can race with refreshed git state. If the backend + // discovers that the branch has no file changes, it should stop before + // calling GitHub so the user gets a local, actionable failure. + test('does not call GitHub when there are no branch changes', async () => { + const gitService = new TestGitService(); + gitService.branchChanges = []; + const octoKitService = new TestOctoKitService(); + const { handler, session } = setup(disposables, gitService, octoKitService); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None), + /no branch changes/, + ); + assert.deepStrictEqual(octoKitService.calls, []); + }); + + test('does not push or call GitHub when branch changes cannot be computed', async () => { + const gitService = new TestGitService(); + gitService.branchChanges = undefined; + const octoKitService = new TestOctoKitService(); + const { handler, session } = setup(disposables, gitService, octoKitService); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None), + /Could not compute branch changes/, + ); + + assert.deepStrictEqual({ gitCalls: gitService.calls, octoCalls: octoKitService.calls }, { + gitCalls: ['hasUncommittedChanges', 'computeSessionFileDiffs'], + octoCalls: [], + }); + }); + + test('returns existing pull request found after create failure', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.createError = new Error('Validation Failed'); + octoKitService.existingAfterCreateFailure = { url: 'https://github.com/microsoft/vscode/pull/8', number: 8 }; + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ message: result.message, octoCalls: octoKitService.calls, createdEvents }, { + message: { markdown: 'Pull request [#8](https://github.com/microsoft/vscode/pull/8) already exists.' }, + octoCalls: ['findPullRequestByHeadBranch:feature/test', 'createPullRequest:false', 'findPullRequestByHeadBranch:feature/test'], + createdEvents: ['agent:/session:feature/test'], + }); + }); + + test('preserves create failure when existing pull request recovery fails', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.createError = new Error('create failed'); + octoKitService.findAfterCreateError = new Error('find failed'); + const { handler, session } = setup(disposables, gitService, octoKitService); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None), + /create failed/, + ); + }); + + test('honors cancellation before mutating the repository', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + const cts = new CancellationTokenSource(); + disposables.add(cts); + cts.cancel(); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, cts.token), + /Pull request operation was cancelled/, + ); + + assert.deepStrictEqual({ gitCalls: gitService.calls, octoCalls: octoKitService.calls, createdEvents }, { + gitCalls: [], + octoCalls: [], + createdEvents: [], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts new file mode 100644 index 0000000000000..89288c227ae3a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; +import type { ISessionGitState } from '../../common/state/sessionState.js'; + +const githubBranchWithUncommittedChanges: ISessionGitState = { + hasGitHubRemote: true, + branchName: 'feature/test', + uncommittedChanges: 1, + outgoingChanges: 0, +}; + +suite('AgentHostPullRequestOperationContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(): AgentHostPullRequestOperationContribution { + return disposables.add(new AgentHostPullRequestOperationContribution( + disposables.add(new AgentHostStateManager(new NullLogService())), + disposables.add(new InstantiationService()), + )); + } + + test('advertises PR operations for GitHub branches with uncommitted changes', () => { + const provider = createContribution(); + + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges }); + + assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-draft-pr']); + }); + + test('does not advertise PR operations without GitHub branch changes', () => { + const provider = createContribution(); + + const actual = [ + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, hasGitHubRemote: false } }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 0 } }), + ]; + + assert.deepStrictEqual(actual, [undefined, undefined]); + }); + + test('hides PR operations immediately after handler reports PR creation', () => { + const provider = createContribution(); + + provider.onPullRequestCreated({ sessionKey: 'agent:/session', branchName: 'feature/test' }); + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges }); + + assert.deepStrictEqual({ operations }, { + operations: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5e64e62b9d90d..5d496af0b55f2 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -19,7 +19,7 @@ import { hasKey } from '../../../../base/common/types.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentSession } from '../../common/agentService.js'; +import { AgentSession, GITHUB_REPO_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; @@ -874,6 +874,9 @@ suite('AgentService (node dispatcher)', () => { removeWorktree: async () => { }, branchExists: async () => false, hasUncommittedChanges: async () => false, + commitAll: async () => { }, + hasUpstream: async () => false, + pushBranch: async () => { }, getSessionGitState: async (uri: URI) => { calls.push(uri.fsPath); return gitState; }, computeSessionFileDiffs: async () => undefined, showBlob: async () => undefined, @@ -922,6 +925,9 @@ suite('AgentService (node dispatcher)', () => { removeWorktree: async () => { }, branchExists: async () => false, hasUncommittedChanges: async () => false, + commitAll: async () => { }, + hasUpstream: async () => false, + pushBranch: async () => { }, getSessionGitState: async () => undefined, computeSessionFileDiffs: async () => undefined, showBlob: async () => undefined, @@ -1192,6 +1198,18 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(copilotAgent.authenticateCalls.length, 0); }); + test('stores GitHub repo token without a matching provider', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: GITHUB_REPO_PROTECTED_RESOURCE.resource, token: 'repo-token' }); + + assert.deepStrictEqual({ result, token: service.getAuthToken(GITHUB_REPO_PROTECTED_RESOURCE.resource), authenticateCalls: copilotAgent.authenticateCalls }, { + result: { authenticated: true }, + token: 'repo-token', + authenticateCalls: [], + }); + }); + test('fans out to every provider that owns the resource', async () => { // Two providers share the same protected resource (the real // motivating example: both Copilot CLI and Claude consume the diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f345884fbef9d..2fcb4f85bcf70 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -88,6 +88,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async hasUncommittedChanges(workingDirectory: URI): Promise { return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); } + async commitAll(): Promise { } + async hasUpstream(): Promise { return false; } + async pushBranch(): Promise { } async getSessionGitState(): Promise { return undefined; } async computeSessionFileDiffs(): Promise { return undefined; } async showBlob(): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 7ff53deb9b37f..085c794c7a1ef 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -27,6 +27,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async hasUncommittedChanges(): Promise { return false; } + async commitAll(): Promise { } + async hasUpstream(): Promise { return false; } + async pushBranch(): Promise { } async getSessionGitState(): Promise { return undefined; } async computeSessionFileDiffs(): Promise { return undefined; } async showBlob(): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 6a0b932656293..8a16c546383fd 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -127,6 +127,7 @@ class MockAgentService implements IAgentService { unsubscribe(_resource: URI, _clientId: string): void { } async shutdown(): Promise { } async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } + getAuthToken(_resource: string): string | undefined { return undefined; } async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } async resourceList(uri: URI): Promise { this.browsedUris.push(uri); @@ -149,6 +150,7 @@ class MockAgentService implements IAgentService { async resourceMove(): Promise<{}> { return {}; } async createTerminal(): Promise { } async disposeTerminal(): Promise { } + async invokeChangesetOperation(): Promise<{}> { return {}; } dispose(): void { this._onDidAction.dispose(); diff --git a/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts new file mode 100644 index 0000000000000..b060f3f895088 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { AgentHostOctoKitService, type FetchFunction } from '../../../node/shared/agentHostOctoKitService.js'; + +type Captured = { url: string; init: RequestInit | undefined }; + +function getUrl(input: string | URL | Request): string { + if (typeof input === 'string') { + return input; + } + return input instanceof URL ? input.href : input.url; +} + +function makeService(fetchImpl: FetchFunction): AgentHostOctoKitService { + return new AgentHostOctoKitService(fetchImpl, new NullLogService()); +} + +function signal(): AbortSignal { + return new AbortController().signal; +} + +function capturingFetch(response: Response): { fetch: FetchFunction; captured: () => Captured } { + let lastCapture: Captured = { url: '', init: undefined }; + const impl: FetchFunction = async (input, init) => { + lastCapture = { url: getUrl(input), init }; + return response; + }; + return { fetch: impl, captured: () => lastCapture }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +suite('AgentHostOctoKitService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('createPullRequest posts the expected request and parses the response', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/42', number: 42 })); + const service = makeService(fetch); + + const result = await service.createPullRequest('o', 'r', 'My PR', 'Body', 'feature', 'main', false, 'gh-token', signal()); + + assert.deepStrictEqual(result, { url: 'https://github.com/o/r/pull/42', number: 42 }); + + const cap = captured(); + assert.strictEqual(cap.url, 'https://api.github.com/repos/o/r/pulls'); + assert.strictEqual(cap.init?.method, 'POST'); + const headers = cap.init?.headers as Record; + assert.strictEqual(headers['Authorization'], 'Bearer gh-token'); + assert.strictEqual(headers['Accept'], 'application/vnd.github+json'); + assert.strictEqual(headers['X-GitHub-Api-Version'], '2022-11-28'); + assert.strictEqual(headers['Content-Type'], 'application/json'); + assert.deepStrictEqual(JSON.parse(cap.init?.body as string), { + title: 'My PR', + body: 'Body', + head: 'feature', + base: 'main', + draft: false, + }); + }); + + test('createPullRequest forwards the draft flag', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/7', number: 7 })); + const service = makeService(fetch); + + await service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', true, 'tok', signal()); + + const sent = JSON.parse(captured().init?.body as string) as { draft: boolean }; + assert.strictEqual(sent.draft, true); + }); + + test('createPullRequest forwards the abort signal', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/7', number: 7 })); + const service = makeService(fetch); + const controller = new AbortController(); + + await service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', true, 'tok', controller.signal); + + assert.strictEqual(captured().init?.signal, controller.signal); + }); + + test('findPullRequestByHeadBranch fetches the latest matching pull request', async () => { + const { fetch, captured } = capturingFetch(jsonResponse([{ html_url: 'https://github.com/o/r/pull/9', number: 9 }])); + const service = makeService(fetch); + + const result = await service.findPullRequestByHeadBranch('o', 'r', 'feature/test', 'tok', signal()); + + assert.deepStrictEqual({ + result, + url: captured().url, + method: captured().init?.method, + }, { + result: { url: 'https://github.com/o/r/pull/9', number: 9 }, + url: 'https://api.github.com/repos/o/r/pulls?head=o%3Afeature%2Ftest&state=all&sort=updated&direction=desc&per_page=1', + method: 'GET', + }); + }); + + test('createPullRequest throws on non-OK response', async () => { + const service = makeService(capturingFetch(new Response('{"message":"Validation Failed"}', { status: 422, statusText: 'Unprocessable Entity' })).fetch); + + await assert.rejects( + () => service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', false, 'tok', signal()), + /422 Unprocessable Entity - {"message":"Validation Failed"}/, + ); + }); + + test('createPullRequest truncates long non-OK response bodies', async () => { + const service = makeService(capturingFetch(new Response(`prefix\n${'x'.repeat(600)}`, { status: 500, statusText: 'Server Error' })).fetch); + + await assert.rejects( + () => service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', false, 'tok', signal()), + err => err instanceof Error && err.message.includes(`prefix ${'x'.repeat(493)}...`) && !err.message.includes('x'.repeat(600)), + ); + }); + + test('createPullRequest throws when response is missing html_url or number', async () => { + const service = makeService(capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/1' /* missing number */ })).fetch); + + await assert.rejects( + () => service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', false, 'tok', signal()), + /Failed to create pull request for o\/r/, + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 411870696360c..fad3daf7cd584 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -12,6 +12,7 @@ import type { IAgentSubscription } from '../../../../../../platform/agentHost/co import { StateComponents, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -212,6 +213,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._logCall('disposeTerminal', terminal, () => this._inner.disposeTerminal(terminal)); } + async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { + return this._logCall('invokeChangesetOperation', params, () => this._inner.invokeChangesetOperation(params)); + } + get rootState(): IAgentSubscription { return this._rootState; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index d1d619e810724..7f299fa539535 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -57,6 +57,8 @@ class MockAgentConnection implements IAgentConnection { this.disposedTerminals.push(terminal); } + async invokeChangesetOperation(): Promise<{}> { return {}; } + /** Simulate the server sending an action to the client */ fireAction(channel: URI, action: StateAction, serverSeq = 1): void { this._onDidAction.fire({ channel: channel.toString(), action, serverSeq, origin: { clientId: 'server', clientSeq: 0 } }); diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 995d359f0cd1c..2564b82c9ef2f 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -21,6 +21,7 @@ import { AgentHostIpcChannelTransport } from '../../../../platform/agentHost/bro import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import type { IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ComponentToState, RootState, StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -208,6 +209,10 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().disposeTerminal(terminal); } + invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { + return this._requireClient().invokeChangesetOperation(params); + } + resourceList(uri: URI): Promise { return this._requireClient().resourceList(uri); }