Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/vs/platform/agentHost/browser/nullAgentHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +52,7 @@ export class NullAgentHostService implements IAgentHostService {
async disposeSession(_session: URI): Promise<void> { }
async createTerminal(_params: CreateTerminalParams): Promise<void> { notSupported(); }
async disposeTerminal(_terminal: URI): Promise<void> { }
async invokeChangesetOperation(_params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> { return notSupported(); }
async resourceList(_uri: URI): Promise<ResourceListResult> { return notSupported(); }
async resourceRead(_uri: URI): Promise<ResourceReadResult> { return notSupported(); }
async resourceWrite(_params: ResourceWriteParams): Promise<ResourceWriteResult> { return notSupported(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -825,6 +826,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
await this._sendRequest('disposeTerminal', { channel: terminal.toString() });
}

async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
return await this._sendRequest('invokeChangesetOperation', params);
}

/**
* List all sessions from the remote agent host.
*/
Expand Down
36 changes: 36 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -738,6 +759,15 @@ export interface IAgentService {
*/
authenticate(params: AuthenticateParams): Promise<AuthenticateResult>;

/**
* 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;
Copy link
Copy Markdown
Contributor Author

@DonJayamanne DonJayamanne May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth token changes.


/** List all available sessions from the Copilot CLI. */
listSessions(): Promise<IAgentSessionMetadata[]>;

Expand Down Expand Up @@ -780,6 +810,9 @@ export interface IAgentService {
/** Dispose a terminal and kill its process if still running. */
disposeTerminal(terminal: URI): Promise<void>;

/** Invoke a server-defined changeset operation. */
invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult>;

/** Gracefully shut down all sessions and the underlying client. */
shutdown(): Promise<void>;

Expand Down Expand Up @@ -922,6 +955,9 @@ export interface IAgentConnection {
createTerminal(params: CreateTerminalParams): Promise<void>;
disposeTerminal(terminal: URI): Promise<void>;

// ---- Changeset operations -----------------------------------------------
invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult>;

// ---- Filesystem operations ----------------------------------------------
resourceList(uri: URI): Promise<ResourceListResult>;
resourceRead(uri: URI): Promise<ResourceReadResult>;
Expand Down
56 changes: 56 additions & 0 deletions src/vs/platform/agentHost/common/changesetOperation.ts
Original file line number Diff line number Diff line change
@@ -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<InvokeChangesetOperationResult>;
}

/**
* 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<void>;
}

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<InvokeChangesetOperationResult>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -223,6 +224,9 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos
disposeTerminal(terminal: URI): Promise<void> {
return this._proxy.disposeTerminal(terminal);
}
invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
return this._proxy.invokeChangesetOperation(params);
}
shutdown(): Promise<void> {
return this._proxy.shutdown();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IChangesetOperationContribution>();
private readonly _handlerRegistrations = this._register(new DisposableMap<IChangesetOperationContribution>());
private readonly _changesetOperationHandlers = new Map<string, IChangesetOperationHandler>();
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<void> {
const gitState = await this._sessionGitStateService.refreshSessionGitState(sessionKey);
if (gitState) {
this.updateOperations(sessionKey, gitState);
}
}

async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading