Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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