mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-08 15:55:15 +01:00
feat: Implement session Git state management and commit operations (#318734)
* feat: Implement session Git state management and commit operations * refactor: Remove unused methods and imports from AgentService
This commit is contained in:
@@ -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 { IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js';
|
||||
import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js';
|
||||
@@ -52,6 +53,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(); }
|
||||
|
||||
@@ -32,6 +32,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';
|
||||
@@ -827,6 +828,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.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js';
|
||||
import type { IAgentSubscription } from './state/agentSubscription.js';
|
||||
import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.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, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js';
|
||||
@@ -350,11 +351,24 @@ export interface AuthenticateParams {
|
||||
* {@link IAuthorizationProtectedResourceMetadata} that this token targets.
|
||||
*/
|
||||
readonly resource: string;
|
||||
/**
|
||||
* Scopes that were used to acquire the token. Omitted for legacy clients
|
||||
* that can only identify tokens by protected resource.
|
||||
*/
|
||||
readonly scopes?: readonly string[];
|
||||
|
||||
/** The bearer token value (RFC 6750). */
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
/** Request for a previously accepted bearer token. */
|
||||
export interface IAgentHostAuthTokenRequest {
|
||||
/** Protected resource identifier from {@link ProtectedResourceMetadata.resource}. */
|
||||
readonly resource: string;
|
||||
/** Required token scopes, when the caller needs a scope-specific token. */
|
||||
readonly scopes?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the `authenticate` command.
|
||||
*/
|
||||
@@ -780,6 +794,9 @@ export interface IAgentService {
|
||||
*/
|
||||
authenticate(params: AuthenticateParams): Promise<AuthenticateResult>;
|
||||
|
||||
/** Return a bearer token previously supplied via {@link authenticate}. */
|
||||
getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined;
|
||||
|
||||
/** List all available sessions from the Copilot CLI. */
|
||||
listSessions(): Promise<IAgentSessionMetadata[]>;
|
||||
|
||||
@@ -822,6 +839,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>;
|
||||
|
||||
@@ -1002,6 +1022,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>;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { ChangesetKind } from './changesetUri.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js';
|
||||
import type { ChangesetOperation, ISessionGitState, URI } 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 {
|
||||
/**
|
||||
* Executes a previously advertised changeset operation.
|
||||
*
|
||||
* The handler receives the original protocol params so it can inspect the
|
||||
* changeset channel and optional target. Validation that the operation exists
|
||||
* on the changeset and supports the requested target scope happens before this
|
||||
* method is called.
|
||||
*/
|
||||
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;
|
||||
/** Expanded changeset URI whose operations are being computed. */
|
||||
readonly changesetUri: URI;
|
||||
/** Well-known changeset kind for {@link changesetUri}. */
|
||||
readonly changesetKind: ChangesetKind;
|
||||
/** Current git metadata for the session used to compute operation availability. */
|
||||
readonly gitState: ISessionGitState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration surface handed to changeset operation contributions.
|
||||
*
|
||||
* Contributions use this object to install operation handlers and request a
|
||||
* refresh when external state changes which operations should be advertised.
|
||||
*/
|
||||
export interface IChangesetOperationRegistry {
|
||||
/**
|
||||
* Registers the server-side handler for one {@link ChangesetOperation.id}.
|
||||
* The returned disposable removes only this registration.
|
||||
*/
|
||||
registerChangesetOperationHandler(operationId: string, handler: IChangesetOperationHandler): IDisposable;
|
||||
/**
|
||||
* Notifies the contribution service that advertised operations for all static
|
||||
* changesets in `sessionKey` should be recomputed from current session state.
|
||||
*/
|
||||
onDidChangeOperations(sessionKey: string): void;
|
||||
/**
|
||||
* Recomputes the session's git metadata and then refreshes advertised
|
||||
* operations if that metadata can be resolved.
|
||||
*/
|
||||
refreshSessionGitState(sessionKey: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider of changeset operations for one feature area.
|
||||
*
|
||||
* A contribution owns the decision about which operations are available for a
|
||||
* changeset and registers the handlers that execute those operations.
|
||||
*/
|
||||
export interface IChangesetOperationContribution extends IDisposable {
|
||||
/**
|
||||
* Registers every operation handler owned by this contribution. Called once
|
||||
* when the contribution is added to the service.
|
||||
*/
|
||||
registerHandlers(registry: IChangesetOperationRegistry): IDisposable;
|
||||
/**
|
||||
* Returns operations that should be advertised for the given changeset, or
|
||||
* `undefined` when this contribution has nothing to offer in the context.
|
||||
*/
|
||||
getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates changeset operation contributions, advertised operation state,
|
||||
* and client-triggered invocation.
|
||||
*/
|
||||
export interface IChangesetOperationContributionService extends IDisposable {
|
||||
/**
|
||||
* Adds a contribution and registers its handlers. Disposing the returned value
|
||||
* unregisters the handlers and disposes the contribution.
|
||||
*/
|
||||
registerContribution(contribution: IChangesetOperationContribution): IDisposable;
|
||||
/**
|
||||
* Collects all operations advertised by registered contributions for a
|
||||
* changeset context.
|
||||
*/
|
||||
getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined;
|
||||
/**
|
||||
* Recomputes and publishes operations for the session and uncommitted static
|
||||
* changesets using the supplied git state.
|
||||
*/
|
||||
updateOperations(sessionKey: string, gitState: ISessionGitState): void;
|
||||
/**
|
||||
* Invokes an advertised operation after validating the changeset, operation id,
|
||||
* and requested target scope.
|
||||
*/
|
||||
invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult>;
|
||||
}
|
||||
@@ -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 { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js';
|
||||
import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
@@ -224,6 +225,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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { ILogService } from '../../log/common/log.js';
|
||||
import type { AuthenticateParams, AuthenticateResult, IAgent, IAgentHostAuthTokenRequest } from '../common/agentService.js';
|
||||
|
||||
interface IStoredAuthToken {
|
||||
readonly resource: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export class AgentHostAuthenticationService {
|
||||
|
||||
private readonly _tokens = new Map<string, IStoredAuthToken>();
|
||||
|
||||
constructor(
|
||||
private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
async authenticate(params: AuthenticateParams, providers: Iterable<IAgent>): Promise<AuthenticateResult> {
|
||||
this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`);
|
||||
// Multiple providers may share the same protected resource (e.g.
|
||||
// both Copilot CLI and Claude consume the GitHub Copilot token).
|
||||
// Fan out to every matching provider in parallel; the request is
|
||||
// considered authenticated if at least one accepts. Provider
|
||||
// failures are isolated -- one provider rejecting (e.g. proxy
|
||||
// server bind failure) MUST NOT prevent another provider from
|
||||
// accepting the same token.
|
||||
const matching = [...providers].filter(
|
||||
p => p.getProtectedResources().some(r => r.resource === params.resource),
|
||||
);
|
||||
const settled = await Promise.allSettled(
|
||||
matching.map(p => p.authenticate(params.resource, params.token)),
|
||||
);
|
||||
let authenticated = false;
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const result = settled[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
authenticated ||= result.value;
|
||||
} else {
|
||||
this._logService.error(
|
||||
result.reason,
|
||||
`[AgentHostAuthenticationService] Provider '${matching[i].id}' authenticate threw for resource=${params.resource}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (authenticated) {
|
||||
const scopes = this._normalizeScopes(params.scopes);
|
||||
this._tokens.set(this._key(params.resource, scopes), { resource: params.resource, scopes, token: params.token });
|
||||
}
|
||||
return { authenticated };
|
||||
}
|
||||
|
||||
getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined {
|
||||
const scopes = this._normalizeScopes(request.scopes);
|
||||
const exact = this._tokens.get(this._key(request.resource, scopes));
|
||||
if (exact) {
|
||||
return exact.token;
|
||||
}
|
||||
if (scopes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requested = new Set(scopes);
|
||||
let best: IStoredAuthToken | undefined;
|
||||
for (const candidate of this._tokens.values()) {
|
||||
if (candidate.resource !== request.resource || candidate.scopes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!this._containsAll(candidate.scopes, requested)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || candidate.scopes.length < best.scopes.length) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
return best.token;
|
||||
}
|
||||
|
||||
// Compatibility for clients that resolved the right token before scopes
|
||||
// were forwarded through the authenticate command.
|
||||
return this._tokens.get(this._key(request.resource, []))?.token;
|
||||
}
|
||||
|
||||
private _containsAll(scopes: readonly string[], requested: ReadonlySet<string>): boolean {
|
||||
for (const scope of requested) {
|
||||
if (!scopes.includes(scope)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _key(resource: string, scopes: readonly string[]): string {
|
||||
return `${resource}\x00${scopes.join('\x00')}`;
|
||||
}
|
||||
|
||||
private _normalizeScopes(scopes: readonly string[] | undefined): readonly string[] {
|
||||
return scopes ? [...new Set(scopes)].sort() : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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, buildUncommittedChangesetUri, ChangesetKind } 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 _handlerRegistrations = this._register(new DisposableMap<IChangesetOperationContribution>());
|
||||
private readonly _changesetOperationHandlers = new Map<string, IChangesetOperationHandler>();
|
||||
private readonly _inFlightOperations = new Map<string, Promise<InvokeChangesetOperationResult>>();
|
||||
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._handlerRegistrations.has(contribution)) {
|
||||
throw new Error('Changeset operation contribution already registered');
|
||||
}
|
||||
this._handlerRegistrations.set(contribution, contribution.registerHandlers(this._registry));
|
||||
return toDisposable(() => {
|
||||
this._handlerRegistrations.deleteAndDispose(contribution);
|
||||
contribution.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined {
|
||||
const operations: ChangesetOperation[] = [];
|
||||
for (const contribution of this._handlerRegistrations.keys()) {
|
||||
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 {
|
||||
this._updateOperationsForChangeset(sessionKey, buildSessionChangesetUri(sessionKey), ChangesetKind.Session, gitState);
|
||||
this._updateOperationsForChangeset(sessionKey, buildUncommittedChangesetUri(sessionKey), ChangesetKind.Uncommitted, gitState);
|
||||
}
|
||||
|
||||
private _updateOperationsForChangeset(sessionKey: string, changesetUri: string, changesetKind: ChangesetKind, gitState: ISessionGitState): void {
|
||||
const operations = this.getOperations({ sessionKey, changesetUri, changesetKind, gitState });
|
||||
this._stateManager.dispatchServerAction(changesetUri, {
|
||||
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}`);
|
||||
}
|
||||
const operationKey = `${params.channel}\x00${params.operationId}\x00${JSON.stringify(params.target ?? null)}`;
|
||||
const inFlight = this._inFlightOperations.get(operationKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
const invoked = handler.invoke(params, CancellationToken.None).finally(() => {
|
||||
if (this._inFlightOperations.get(operationKey) === invoked) {
|
||||
this._inFlightOperations.delete(operationKey);
|
||||
}
|
||||
});
|
||||
this._inFlightOperations.set(operationKey, invoked);
|
||||
return invoked;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.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(AgentHostCommitOperationContribution, stateManager)
|
||||
));
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { basename } from '../../../base/common/resources.js';
|
||||
import { CancellationToken } from '../../../base/common/cancellation.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { GITHUB_COPILOT_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js';
|
||||
import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js';
|
||||
import { type IChangesetOperationHandler } from '../common/changesetOperation.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
||||
import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
|
||||
import { readSessionGitState, type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { IAgentHostGitService } from './agentHostGitService.js';
|
||||
import { IAgentHostChangesetService } from './agentHostChangesetService.js';
|
||||
import { CopilotApiError, ICopilotApiService } from './shared/copilotApiService.js';
|
||||
|
||||
const MAX_CHANGE_SUMMARY_PROMPT_CHARS = 20_000;
|
||||
|
||||
export class AgentHostCommitOperationHandler implements IChangesetOperationHandler {
|
||||
|
||||
public static readonly OPERATION_COMMIT = 'commit';
|
||||
|
||||
constructor(
|
||||
private readonly _getSessionState: (sessionKey: string) => SessionState | undefined,
|
||||
private readonly _onCommitted: (sessionKey: string) => Promise<void>,
|
||||
@IAgentService private readonly _agentService: IAgentService,
|
||||
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
|
||||
@ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
|
||||
@IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise<InvokeChangesetOperationResult> {
|
||||
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<InvokeChangesetOperationResult> {
|
||||
const parsed = parseChangesetUri(params.channel);
|
||||
if (!parsed || parsed.kind !== ChangesetKind.Uncommitted) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not an uncommitted 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) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session's working directory is not a git repo: ${sessionUri}`);
|
||||
}
|
||||
|
||||
const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory);
|
||||
if (!hasUncommitted) {
|
||||
return { message: { markdown: localize('agentHost.changeset.commit.noChanges', "No uncommitted changes to commit.") } };
|
||||
}
|
||||
this._throwIfCancelled(token);
|
||||
|
||||
const authToken = this._agentService.getAuthToken({
|
||||
resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource,
|
||||
scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported,
|
||||
});
|
||||
if (!authToken) {
|
||||
throw new ProtocolError(
|
||||
AHP_AUTH_REQUIRED,
|
||||
localize('agentHost.changeset.commit.authRequired', "Sign in to GitHub Copilot to generate a commit message."),
|
||||
[GITHUB_COPILOT_PROTECTED_RESOURCE],
|
||||
);
|
||||
}
|
||||
|
||||
const diffs = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri });
|
||||
if (!diffs || diffs.length === 0) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.commit.diffFailed', "Could not compute uncommitted changes to generate a commit message."));
|
||||
}
|
||||
this._throwIfCancelled(token);
|
||||
|
||||
let message: string;
|
||||
try {
|
||||
message = this._cleanCommitMessage(await this._copilotApiService.utilityChatCompletion(authToken, {
|
||||
messages: this._buildCommitMessagePrompt(workingDirectory, gitState.branchName, diffs),
|
||||
}, { signal }));
|
||||
} catch (err) {
|
||||
this._throwIfCancelled(token);
|
||||
if (this._isAuthFailure(err)) {
|
||||
throw new ProtocolError(
|
||||
AHP_AUTH_REQUIRED,
|
||||
localize('agentHost.changeset.commit.authExpired', "Authentication is required to generate a commit message. Please sign in to GitHub Copilot and try again."),
|
||||
[GITHUB_COPILOT_PROTECTED_RESOURCE],
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!message) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.commit.emptyMessage', "Generated commit message was empty."));
|
||||
}
|
||||
this._throwIfCancelled(token);
|
||||
|
||||
this._logService.info(`[AgentHostCommitOperationHandler] Committing uncommitted changes for session ${sessionUri}`);
|
||||
try {
|
||||
await this._gitService.commitAll(workingDirectory, message);
|
||||
} catch (err) {
|
||||
this._throwIfCancelled(token);
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to commit changes: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this._onCommitted(sessionUri);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCommitOperationHandler] Post-commit refresh failed for session ${sessionUri}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
this._changesets.refreshUncommittedChangeset(sessionUri);
|
||||
this._changesets.refreshSessionChangeset(sessionUri);
|
||||
|
||||
return { message: { markdown: localize('agentHost.changeset.commit.committed', "Committed changes with message: `{0}`", message.split('\n')[0]) } };
|
||||
}
|
||||
|
||||
private _buildCommitMessagePrompt(workingDirectory: URI, branchName: string | undefined, diffs: readonly ISessionFileDiff[]): { role: 'system' | 'user'; content: string }[] {
|
||||
const changeSummary = this._summarizeDiffsForPrompt(diffs);
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'You generate concise Git commit messages.',
|
||||
'Return only the commit message text, with no markdown or code fences.',
|
||||
'Use imperative mood. Keep the subject line under 72 characters.',
|
||||
'Add a body only when it helps explain multiple related changes.',
|
||||
].join(' '),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
`Repository: ${basename(workingDirectory)}`,
|
||||
`Branch: ${branchName ?? 'unknown'}`,
|
||||
'Changed files:',
|
||||
changeSummary,
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private _summarizeDiffsForPrompt(diffs: readonly ISessionFileDiff[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const diff of diffs) {
|
||||
const before = diff.before?.uri;
|
||||
const after = diff.after?.uri;
|
||||
const path = after ?? before ?? '(unknown)';
|
||||
let kind = 'Edit';
|
||||
if (!before && after) {
|
||||
kind = 'Create';
|
||||
} else if (before && !after) {
|
||||
kind = 'Delete';
|
||||
} else if (before && after && before !== after) {
|
||||
kind = 'Rename';
|
||||
}
|
||||
lines.push(`- ${kind}: ${this._displayUri(path)} (+${diff.diff?.added ?? 0} -${diff.diff?.removed ?? 0})`);
|
||||
if (lines.join('\n').length > MAX_CHANGE_SUMMARY_PROMPT_CHARS) {
|
||||
lines.push('[file list truncated]');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private _displayUri(uri: string): string {
|
||||
try {
|
||||
const parsed = URI.parse(uri);
|
||||
return parsed.scheme === 'file' ? parsed.fsPath : parsed.path || uri;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanCommitMessage(raw: string): string {
|
||||
let text = raw.trim().replace(/\r\n/g, '\n');
|
||||
const fenced = /^```(?:text|gitcommit)?\s*([\s\S]*?)\s*```$/i.exec(text);
|
||||
if (fenced) {
|
||||
text = fenced[1].trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private _isAuthFailure(err: unknown): boolean {
|
||||
if (err instanceof CopilotApiError) {
|
||||
return err.status === 401 || err.status === 403;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return /\b(401|403)\b/.test(message)
|
||||
&& /\b(auth|authorization|unauthorized|forbidden|token|copilot endpoint discovery|copilot session token mint)\b/i.test(message);
|
||||
}
|
||||
|
||||
private _throwIfCancelled(token: CancellationToken): void {
|
||||
if (token.isCancellationRequested) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.commit.cancelled', "Commit operation was cancelled."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
import { ChangesetKind } from '../common/changesetUri.js';
|
||||
import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/changesetOperation.js';
|
||||
import { ChangesetOperationScope, type ChangesetOperation } from '../common/state/sessionState.js';
|
||||
import { AgentHostCommitOperationHandler } from './agentHostCommitOperationHandler.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
|
||||
export class AgentHostCommitOperationContribution extends Disposable implements IChangesetOperationContribution {
|
||||
|
||||
private _registry: IChangesetOperationRegistry | undefined;
|
||||
|
||||
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 handler = this._instantiationService.createInstance(AgentHostCommitOperationHandler, getSessionState, sessionKey => this._onCommitted(sessionKey));
|
||||
store.add(registry.registerChangesetOperationHandler(AgentHostCommitOperationHandler.OPERATION_COMMIT, handler));
|
||||
store.add({ dispose: () => { this._registry = undefined; } });
|
||||
return store;
|
||||
}
|
||||
|
||||
getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined {
|
||||
if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [{
|
||||
id: AgentHostCommitOperationHandler.OPERATION_COMMIT,
|
||||
label: localize('agentHost.changeset.commit', "Commit"),
|
||||
scopes: [ChangesetOperationScope.Changeset],
|
||||
icon: 'git-commit',
|
||||
}];
|
||||
}
|
||||
|
||||
private async _onCommitted(sessionKey: string): Promise<void> {
|
||||
this._registry?.onDidChangeOperations(sessionKey);
|
||||
await this._registry?.refreshSessionGitState(sessionKey);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,27 @@ export interface IAgentHostGitService {
|
||||
* worktree that still contains uncommitted work.
|
||||
*/
|
||||
hasUncommittedChanges(workingDirectory: URI): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Returns true when the named branch has an upstream tracking ref
|
||||
* (i.e. `<branch>@{upstream}` resolves). Used before {@link pushBranch}
|
||||
* to decide whether `--set-upstream` is needed.
|
||||
*/
|
||||
hasUpstream(workingDirectory: URI, branchName: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* Computes the {@link ISessionGitState} for the working directory by
|
||||
* shelling out to `git`. Returns undefined if the directory is not a
|
||||
@@ -275,6 +296,25 @@ export class AgentHostGitService implements IAgentHostGitService {
|
||||
return !!output && output.trim().length > 0;
|
||||
}
|
||||
|
||||
async commitAll(workingDirectory: URI, message: string): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<readonly ISessionFileDiff[] | undefined> {
|
||||
// Bail fast if not inside a git work tree so callers can fall back
|
||||
// to other diff sources.
|
||||
|
||||
@@ -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, AgentHostCodexAgentBinaryPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js';
|
||||
import { AgentHostClaudeSdkPathEnvVar, AgentHostCodexAgentBinaryPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService } from '../common/agentService.js';
|
||||
import { AgentService } from './agentService.js';
|
||||
import { IAgentConfigurationService } from './agentConfigurationService.js';
|
||||
import { IAgentHostCompletions } from './agentHostCompletions.js';
|
||||
@@ -160,6 +160,7 @@ async function startAgentHost(): Promise<void> {
|
||||
const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService));
|
||||
diServices.set(IAgentHostOTelService, agentHostOTelService);
|
||||
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService);
|
||||
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));
|
||||
|
||||
@@ -42,7 +42,7 @@ import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService
|
||||
import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js';
|
||||
import { AgentHostOTelService } from './otel/agentHostOTelService.js';
|
||||
import { AgentService } from './agentService.js';
|
||||
import { AgentHostClaudeSdkPathEnvVar, AgentHostCodexAgentBinaryPathEnvVar } from '../common/agentService.js';
|
||||
import { AgentHostClaudeSdkPathEnvVar, IAgentService, AgentHostCodexAgentBinaryPathEnvVar } from '../common/agentService.js';
|
||||
import { IAgentConfigurationService } from './agentConfigurationService.js';
|
||||
import { IAgentHostCompletions } from './agentHostCompletions.js';
|
||||
import { IAgentHostTerminalManager } from './agentHostTerminalManager.js';
|
||||
@@ -227,6 +227,7 @@ async function main(): Promise<void> {
|
||||
// Create the agent service (owns AgentHostStateManager + AgentSideEffects internally)
|
||||
const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService);
|
||||
disposables.add(agentService);
|
||||
diServices.set(IAgentService, agentService);
|
||||
|
||||
// Register agents
|
||||
if (!options.quiet) {
|
||||
|
||||
@@ -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<ISessionGitState | undefined> {
|
||||
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<ISessionGitState | undefined> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDis
|
||||
import { ResourceMap } from '../../../base/common/map.js';
|
||||
import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js';
|
||||
import { Schemas } from '../../../base/common/network.js';
|
||||
import { equals as objectEquals } from '../../../base/common/objects.js';
|
||||
import { observableValue } from '../../../base/common/observable.js';
|
||||
import { extname as resourcesExtname, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
@@ -21,15 +20,16 @@ import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProv
|
||||
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, IAgent, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, 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, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, 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, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
|
||||
import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, parseResourceWatchChannelUri, 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';
|
||||
@@ -47,9 +47,14 @@ import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvid
|
||||
import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js';
|
||||
import { AgentHostSkillCompletionProvider } from './agentHostSkillCompletionProvider.js';
|
||||
import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js';
|
||||
import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.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 { AgentHostAuthenticationService } from './agentHostAuthenticationService.js';
|
||||
import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
|
||||
|
||||
/**
|
||||
@@ -101,6 +106,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
private readonly _sessionToProvider = new Map<string, AgentProvider>();
|
||||
/** Subscriptions to provider progress events; cleared when providers change. */
|
||||
private readonly _providerSubscriptions = this._register(new DisposableStore());
|
||||
private readonly _authService: AgentHostAuthenticationService;
|
||||
/** Default provider used when no explicit provider is specified. */
|
||||
private _defaultProvider: AgentProvider | undefined;
|
||||
/** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */
|
||||
@@ -111,6 +117,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;
|
||||
@@ -186,6 +196,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
) {
|
||||
super();
|
||||
this._logService.info('AgentService initialized');
|
||||
this._authService = new AgentHostAuthenticationService(_logService);
|
||||
this._stateManager = this._register(new AgentHostStateManager(_logService, {
|
||||
changesetStateRetention: {
|
||||
// The cache calls this lazily after construction. If a future state-manager
|
||||
@@ -206,6 +217,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],
|
||||
[IAgentHostFileMonitorService, fileMonitorService],
|
||||
@@ -218,6 +230,9 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
[ISessionDataService, this._sessionDataService],
|
||||
);
|
||||
const instantiationService = this._register(new InstantiationService(services, /*strict*/ true));
|
||||
services.set(ICopilotApiService, instantiationService.createInstance(CopilotApiService, undefined));
|
||||
this._sessionGitStateService = this._register(instantiationService.createInstance(AgentHostSessionGitStateService, this._stateManager));
|
||||
this._changesetOperationContributionService = this._register(instantiationService.createInstance(AgentHostChangesetOperationContributionService, this._stateManager, this._sessionGitStateService));
|
||||
|
||||
// The checkpoint service is constructed in the outer agent-host
|
||||
// DI scope and passed via {@link _checkpointService}; register it
|
||||
@@ -237,6 +252,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
// constructor injection resolves naturally.
|
||||
this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager));
|
||||
services.set(IAgentHostChangesetService, this._changesets);
|
||||
this._register(registerDefaultChangesetOperationContributions(this._changesetOperationContributionService, instantiationService, this._stateManager));
|
||||
|
||||
// The coordinator owns all AgentService-side orchestration of the
|
||||
// changeset feature: lifecycle hooks, listSessions overlay,
|
||||
@@ -309,33 +325,17 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
// ---- auth ---------------------------------------------------------------
|
||||
|
||||
async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> {
|
||||
this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`);
|
||||
// Multiple providers may share the same protected resource (e.g.
|
||||
// both Copilot CLI and Claude consume the GitHub Copilot token).
|
||||
// Fan out to every matching provider in parallel; the request is
|
||||
// considered authenticated if at least one accepts. Provider
|
||||
// failures are isolated — one provider rejecting (e.g. proxy
|
||||
// server bind failure) MUST NOT prevent another provider from
|
||||
// accepting the same token.
|
||||
const matching = [...this._providers.values()].filter(
|
||||
p => p.getProtectedResources().some(r => r.resource === params.resource),
|
||||
);
|
||||
const settled = await Promise.allSettled(
|
||||
matching.map(p => p.authenticate(params.resource, params.token)),
|
||||
);
|
||||
let authenticated = false;
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const result = settled[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
authenticated ||= result.value;
|
||||
} else {
|
||||
this._logService.error(
|
||||
result.reason,
|
||||
`[AgentService] Provider '${matching[i].id}' authenticate threw for resource=${params.resource}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { authenticated };
|
||||
return this._authService.authenticate(params, this._providers.values());
|
||||
}
|
||||
|
||||
getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined {
|
||||
return this._authService.getAuthToken(request);
|
||||
}
|
||||
|
||||
// ---- Changeset operation handlers --------------------------------------
|
||||
|
||||
async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
|
||||
return this._changesetOperationContributionService.invokeChangesetOperation(params);
|
||||
}
|
||||
|
||||
// ---- session management -------------------------------------------------
|
||||
@@ -656,25 +656,13 @@ 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;
|
||||
}
|
||||
const current = this._stateManager.getSessionState(sessionKey)?._meta;
|
||||
const currentGitState = readSessionGitState(current);
|
||||
// Skip the meta action if the computed git state hasn't changed; this is
|
||||
// called after every turn, so deduping avoids needless action churn.
|
||||
if (!objectEquals(currentGitState, gitState)) {
|
||||
const next = withSessionGitState(current, gitState);
|
||||
this._stateManager.setSessionMeta(sessionKey, next);
|
||||
}
|
||||
this._updateBranchChangesetDescription(sessionKey, gitState);
|
||||
this._changesetOperationContributionService.updateOperations(sessionKey, gitState);
|
||||
},
|
||||
e => {
|
||||
this._logService.warn(`[AgentService] Failed to compute git state for ${session}`, e);
|
||||
@@ -682,69 +670,6 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
this._stateManager.setSessionChangesets(sessionKey, next);
|
||||
}
|
||||
|
||||
private _persistConfigValues(session: URI, values: Record<string, unknown>): void {
|
||||
let ref;
|
||||
try {
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
type ReconnectParams,
|
||||
type IStateSnapshot,
|
||||
} from '../common/state/sessionProtocol.js';
|
||||
import { ChangesetOperationScope, ChangesetOperationTargetKind, isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js';
|
||||
import { isAhpResourceWatchChannel, 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 {
|
||||
@@ -939,30 +939,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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 } from '../../../../base/common/cancellation.js';
|
||||
import { DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../../common/changesetOperation.js';
|
||||
import { buildUncommittedChangesetUri } from '../../common/changesetUri.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../common/state/protocol/channels-changeset/commands.js';
|
||||
import { ActionType } from '../../common/state/sessionActions.js';
|
||||
import { ChangesetOperationScope, type ChangesetOperation } from '../../common/state/sessionState.js';
|
||||
import { AgentHostChangesetOperationContributionService } from '../../node/agentHostChangesetOperationContributionService.js';
|
||||
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
|
||||
import type { AgentHostSessionGitStateService } from '../../node/agentHostSessionGitStateService.js';
|
||||
|
||||
class TestHandler implements IChangesetOperationHandler {
|
||||
calls = 0;
|
||||
private _resolve: ((value: InvokeChangesetOperationResult) => void) | undefined;
|
||||
readonly pending = new Promise<InvokeChangesetOperationResult>(resolve => { this._resolve = resolve; });
|
||||
|
||||
invoke(_params: InvokeChangesetOperationParams, _token: CancellationToken): Promise<InvokeChangesetOperationResult> {
|
||||
this.calls++;
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
complete(result: InvokeChangesetOperationResult): void {
|
||||
this._resolve?.(result);
|
||||
}
|
||||
}
|
||||
|
||||
class TestContribution implements IChangesetOperationContribution {
|
||||
constructor(private readonly handler: IChangesetOperationHandler) { }
|
||||
|
||||
registerHandlers(registry: IChangesetOperationRegistry): IDisposable {
|
||||
const store = new DisposableStore();
|
||||
store.add(registry.registerChangesetOperationHandler('commit', this.handler));
|
||||
return store;
|
||||
}
|
||||
|
||||
getOperations(_context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
dispose(): void { }
|
||||
}
|
||||
|
||||
suite('AgentHostChangesetOperationContributionService', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('joins duplicate in-flight invocations for the same changeset operation', async () => {
|
||||
const stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
|
||||
const sessionKey = 'agent:/session';
|
||||
const changesetUri = buildUncommittedChangesetUri(sessionKey);
|
||||
stateManager.registerChangeset(changesetUri);
|
||||
stateManager.dispatchServerAction(changesetUri, {
|
||||
type: ActionType.ChangesetOperationsChanged,
|
||||
operations: [{ id: 'commit', label: 'Commit', scopes: [ChangesetOperationScope.Changeset] }],
|
||||
});
|
||||
|
||||
const service = disposables.add(new AgentHostChangesetOperationContributionService(
|
||||
stateManager,
|
||||
{ refreshSessionGitState: async () => undefined } as unknown as AgentHostSessionGitStateService,
|
||||
));
|
||||
const handler = new TestHandler();
|
||||
disposables.add(service.registerContribution(new TestContribution(handler)));
|
||||
|
||||
const params = { channel: changesetUri, operationId: 'commit' };
|
||||
const first = service.invokeChangesetOperation(params);
|
||||
const second = service.invokeChangesetOperation(params);
|
||||
handler.complete({ message: { markdown: 'Committed' } });
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
assert.deepStrictEqual({ calls: handler.calls, firstResult, secondResult }, {
|
||||
calls: 1,
|
||||
firstResult: { message: { markdown: 'Committed' } },
|
||||
secondResult: { message: { markdown: 'Committed' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 type Anthropic from '@anthropic-ai/sdk';
|
||||
import type { CCAModel } from '@vscode/copilot-api';
|
||||
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 { buildUncommittedChangesetUri } from '../../common/changesetUri.js';
|
||||
import { SessionStatus, withSessionGitState, type ISessionFileDiff } from '../../common/state/sessionState.js';
|
||||
import type { IAgentHostGitService } from '../../node/agentHostGitService.js';
|
||||
import { AgentHostCommitOperationHandler } from '../../node/agentHostCommitOperationHandler.js';
|
||||
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
|
||||
import type { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, StaticChangesetKind } from '../../node/agentHostChangesetService.js';
|
||||
import { CopilotApiError, type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js';
|
||||
import { GITHUB_COPILOT_PROTECTED_RESOURCE, IAgentService } from '../../common/agentService.js';
|
||||
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
|
||||
|
||||
class TestGitService implements IAgentHostGitService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly calls: string[] = [];
|
||||
uncommitted = true;
|
||||
diffs: readonly ISessionFileDiff[] | undefined = [{
|
||||
after: { uri: 'file:///repo/file.ts', content: { uri: 'file:///repo/file.ts' } },
|
||||
diff: { added: 1, removed: 0 },
|
||||
}];
|
||||
|
||||
async isInsideWorkTree(): Promise<boolean> { return true; }
|
||||
async getCurrentBranch(): Promise<string | undefined> { return 'feature/test'; }
|
||||
async getDefaultBranch(): Promise<string | undefined> { return 'main'; }
|
||||
async getBranches(): Promise<string[]> { return []; }
|
||||
async getRepositoryRoot(): Promise<URI | undefined> { return URI.file('/repo'); }
|
||||
async getWorktreeRoots(): Promise<URI[]> { return []; }
|
||||
async addWorktree(): Promise<void> { }
|
||||
async addExistingWorktree(): Promise<void> { }
|
||||
async removeWorktree(): Promise<void> { }
|
||||
async branchExists(): Promise<boolean> { return false; }
|
||||
async hasUncommittedChanges(): Promise<boolean> {
|
||||
this.calls.push('hasUncommittedChanges');
|
||||
return this.uncommitted;
|
||||
}
|
||||
async commitAll(_workingDirectory: URI, message: string): Promise<void> {
|
||||
this.calls.push(`commitAll:${message}`);
|
||||
this.uncommitted = false;
|
||||
}
|
||||
async hasUpstream(): Promise<boolean> { return false; }
|
||||
async pushBranch(): Promise<void> { }
|
||||
async getSessionGitState(): Promise<undefined> { return undefined; }
|
||||
async computeSessionFileDiffs(): Promise<readonly ISessionFileDiff[] | undefined> {
|
||||
this.calls.push('computeSessionFileDiffs');
|
||||
return this.diffs;
|
||||
}
|
||||
async showBlob(): Promise<undefined> { return undefined; }
|
||||
async captureWorkingTreeAsTree(): Promise<undefined> { return undefined; }
|
||||
async commitTree(): Promise<undefined> { return undefined; }
|
||||
async updateRef(): Promise<void> { }
|
||||
async deleteRefs(): Promise<void> { }
|
||||
async revParse(): Promise<string | undefined> { return undefined; }
|
||||
async computeFileDiffsBetweenRefs(): Promise<readonly ISessionFileDiff[] | undefined> { return undefined; }
|
||||
}
|
||||
|
||||
class TestCopilotApiService implements ICopilotApiService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly calls: { token: string; request: ICopilotUtilityChatCompletionRequest; options?: ICopilotApiServiceRequestOptions }[] = [];
|
||||
response = '```text\nUpdate session changes\n```';
|
||||
error: Error | undefined;
|
||||
|
||||
messages(_githubToken: string, request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator<Anthropic.MessageStreamEvent>;
|
||||
messages(_githubToken: string, request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise<Anthropic.Message>;
|
||||
messages(): AsyncGenerator<Anthropic.MessageStreamEvent> | Promise<Anthropic.Message> {
|
||||
throw new Error('not used');
|
||||
}
|
||||
responses(
|
||||
githubToken: string,
|
||||
body: string,
|
||||
options?: ICopilotApiServiceRequestOptions,
|
||||
): Promise<Response> {
|
||||
throw new Error('not used');
|
||||
}
|
||||
async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); }
|
||||
async models(): Promise<CCAModel[]> { return []; }
|
||||
async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> {
|
||||
this.calls.push({ token: githubToken, request, options });
|
||||
if (this.error) {
|
||||
throw this.error;
|
||||
}
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
|
||||
class TestChangesetService implements IAgentHostChangesetService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly calls: string[] = [];
|
||||
registerStaticChangesets(): void { }
|
||||
restoreStaticChangeset(_session: string, _kind: StaticChangesetKind, _diffs: readonly ISessionFileDiff[]): void { }
|
||||
parsePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; }
|
||||
applyPersistedStaticChangesets(_sessionUri: string, _diffs: IRestoredChangesetDiffs): void { }
|
||||
restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; }
|
||||
isStaticChangesetComputeActive(): boolean { return false; }
|
||||
refreshUncommittedChangeset(session: string): void { this.calls.push(`refreshUncommitted:${session}`); }
|
||||
refreshSessionChangeset(session: string): void { this.calls.push(`refreshSession:${session}`); }
|
||||
async computeTurnChangeset(_session: string, _turnId: string): Promise<string> { return ''; }
|
||||
async computeCompareTurnsChangeset(_session: string, _originalTurnId: string, _modifiedTurnId: string): Promise<string> { return ''; }
|
||||
onToolCallEditsApplied(_session: string, _turnId: string): void { }
|
||||
onTurnComplete(_session: string, _turnId: string | undefined): void { }
|
||||
onSessionTruncated(_session: string): void { }
|
||||
setTurnSubscriberProbe(_probe: (session: string, turnId: string) => boolean): void { }
|
||||
}
|
||||
|
||||
function createAgentService(token: string | undefined): IAgentService {
|
||||
return {
|
||||
getAuthToken: () => token,
|
||||
} as Partial<IAgentService> as IAgentService;
|
||||
}
|
||||
|
||||
function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitService, copilotApiService: TestCopilotApiService, changesets: TestChangesetService, options?: { readonly onCommittedError?: Error }): { handler: AgentHostCommitOperationHandler; session: URI; committedSessions: string[] } {
|
||||
const stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
|
||||
const session = URI.parse('agent:/session');
|
||||
const committedSessions: 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, {
|
||||
branchName: 'feature/test',
|
||||
uncommittedChanges: 1,
|
||||
}));
|
||||
return {
|
||||
handler: new AgentHostCommitOperationHandler(sessionKey => stateManager.getSessionState(sessionKey), async sessionKey => {
|
||||
committedSessions.push(sessionKey);
|
||||
changesets.calls.push(`onCommitted:${sessionKey}`);
|
||||
if (options?.onCommittedError) {
|
||||
throw options.onCommittedError;
|
||||
}
|
||||
}, createAgentService('gh-repo-token'), gitService, copilotApiService, changesets, new NullLogService()),
|
||||
session,
|
||||
committedSessions,
|
||||
};
|
||||
}
|
||||
|
||||
suite('AgentHostCommitOperationHandler', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('generates a commit message, commits all changes, and refreshes changesets', async () => {
|
||||
const gitService = new TestGitService();
|
||||
const copilotApiService = new TestCopilotApiService();
|
||||
const changesets = new TestChangesetService();
|
||||
const { handler, session, committedSessions } = setup(disposables, gitService, copilotApiService, changesets);
|
||||
|
||||
const result = await handler.invoke({ channel: buildUncommittedChangesetUri(session.toString()), operationId: AgentHostCommitOperationHandler.OPERATION_COMMIT }, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
message: result.message,
|
||||
gitCalls: gitService.calls,
|
||||
completion: copilotApiService.calls.map(call => ({ token: call.token, fileIncluded: call.request.messages.some(message => message.content.includes('file.ts')) })),
|
||||
changesetCalls: changesets.calls,
|
||||
committedSessions,
|
||||
}, {
|
||||
message: { markdown: 'Committed changes with message: `Update session changes`' },
|
||||
gitCalls: ['hasUncommittedChanges', 'computeSessionFileDiffs', 'commitAll:Update session changes'],
|
||||
completion: [{ token: 'gh-repo-token', fileIncluded: true }],
|
||||
changesetCalls: ['onCommitted:agent:/session', 'refreshUncommitted:agent:/session', 'refreshSession:agent:/session'],
|
||||
committedSessions: ['agent:/session'],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns no-op success without generating a message or committing when the working tree is clean', async () => {
|
||||
const gitService = new TestGitService();
|
||||
gitService.uncommitted = false;
|
||||
const copilotApiService = new TestCopilotApiService();
|
||||
const changesets = new TestChangesetService();
|
||||
const { handler, session } = setup(disposables, gitService, copilotApiService, changesets);
|
||||
|
||||
const result = await handler.invoke({ channel: buildUncommittedChangesetUri(session.toString()), operationId: AgentHostCommitOperationHandler.OPERATION_COMMIT }, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({ message: result.message, gitCalls: gitService.calls, completionCalls: copilotApiService.calls.length, changesetCalls: changesets.calls }, {
|
||||
message: { markdown: 'No uncommitted changes to commit.' },
|
||||
gitCalls: ['hasUncommittedChanges'],
|
||||
completionCalls: 0,
|
||||
changesetCalls: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns success when post-commit refresh fails', async () => {
|
||||
const gitService = new TestGitService();
|
||||
const copilotApiService = new TestCopilotApiService();
|
||||
const changesets = new TestChangesetService();
|
||||
const { handler, session, committedSessions } = setup(disposables, gitService, copilotApiService, changesets, { onCommittedError: new Error('refresh failed') });
|
||||
|
||||
const result = await handler.invoke({ channel: buildUncommittedChangesetUri(session.toString()), operationId: AgentHostCommitOperationHandler.OPERATION_COMMIT }, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
message: result.message,
|
||||
gitCalls: gitService.calls,
|
||||
changesetCalls: changesets.calls,
|
||||
committedSessions,
|
||||
}, {
|
||||
message: { markdown: 'Committed changes with message: `Update session changes`' },
|
||||
gitCalls: ['hasUncommittedChanges', 'computeSessionFileDiffs', 'commitAll:Update session changes'],
|
||||
changesetCalls: ['onCommitted:agent:/session', 'refreshUncommitted:agent:/session', 'refreshSession:agent:/session'],
|
||||
committedSessions: ['agent:/session'],
|
||||
});
|
||||
});
|
||||
|
||||
test('honors cancellation before mutating the repository', async () => {
|
||||
const gitService = new TestGitService();
|
||||
const copilotApiService = new TestCopilotApiService();
|
||||
const changesets = new TestChangesetService();
|
||||
const { handler, session } = setup(disposables, gitService, copilotApiService, changesets);
|
||||
const cts = disposables.add(new CancellationTokenSource());
|
||||
cts.cancel();
|
||||
|
||||
await assert.rejects(
|
||||
() => handler.invoke({ channel: buildUncommittedChangesetUri(session.toString()), operationId: AgentHostCommitOperationHandler.OPERATION_COMMIT }, cts.token),
|
||||
/Commit operation was cancelled/,
|
||||
);
|
||||
|
||||
assert.deepStrictEqual({ gitCalls: gitService.calls, completionCalls: copilotApiService.calls.length, changesetCalls: changesets.calls }, {
|
||||
gitCalls: [],
|
||||
completionCalls: 0,
|
||||
changesetCalls: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('maps stale Copilot auth failures to AHP_AUTH_REQUIRED before committing', async () => {
|
||||
const gitService = new TestGitService();
|
||||
const copilotApiService = new TestCopilotApiService();
|
||||
copilotApiService.error = new CopilotApiError(401, {
|
||||
type: 'error',
|
||||
error: { type: 'authentication_error', message: 'bad token' },
|
||||
request_id: null,
|
||||
});
|
||||
const changesets = new TestChangesetService();
|
||||
const { handler, session, committedSessions } = setup(disposables, gitService, copilotApiService, changesets);
|
||||
|
||||
let err: ProtocolError | undefined;
|
||||
try {
|
||||
await handler.invoke({ channel: buildUncommittedChangesetUri(session.toString()), operationId: AgentHostCommitOperationHandler.OPERATION_COMMIT }, CancellationToken.None);
|
||||
} catch (error) {
|
||||
err = error as ProtocolError;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({
|
||||
code: err?.code,
|
||||
data: err?.data,
|
||||
gitCalls: gitService.calls,
|
||||
completionCalls: copilotApiService.calls.length,
|
||||
changesetCalls: changesets.calls,
|
||||
committedSessions,
|
||||
}, {
|
||||
code: AHP_AUTH_REQUIRED,
|
||||
data: [GITHUB_COPILOT_PROTECTED_RESOURCE],
|
||||
gitCalls: ['hasUncommittedChanges', 'computeSessionFileDiffs'],
|
||||
completionCalls: 1,
|
||||
changesetCalls: [],
|
||||
committedSessions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('maps Copilot token mint auth failures to AHP_AUTH_REQUIRED before committing', async () => {
|
||||
const gitService = new TestGitService();
|
||||
const copilotApiService = new TestCopilotApiService();
|
||||
copilotApiService.error = new Error('Copilot session token mint failed: 403 Forbidden');
|
||||
const changesets = new TestChangesetService();
|
||||
const { handler, session, committedSessions } = setup(disposables, gitService, copilotApiService, changesets);
|
||||
|
||||
let err: ProtocolError | undefined;
|
||||
try {
|
||||
await handler.invoke({ channel: buildUncommittedChangesetUri(session.toString()), operationId: AgentHostCommitOperationHandler.OPERATION_COMMIT }, CancellationToken.None);
|
||||
} catch (error) {
|
||||
err = error as ProtocolError;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({
|
||||
code: err?.code,
|
||||
data: err?.data,
|
||||
gitCalls: gitService.calls,
|
||||
completionCalls: copilotApiService.calls.length,
|
||||
changesetCalls: changesets.calls,
|
||||
committedSessions,
|
||||
}, {
|
||||
code: AHP_AUTH_REQUIRED,
|
||||
data: [GITHUB_COPILOT_PROTECTED_RESOURCE],
|
||||
gitCalls: ['hasUncommittedChanges', 'computeSessionFileDiffs'],
|
||||
completionCalls: 1,
|
||||
changesetCalls: [],
|
||||
committedSessions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind } from '../../common/changesetUri.js';
|
||||
import type { ISessionGitState } from '../../common/state/sessionState.js';
|
||||
import { AgentHostCommitOperationContribution } from '../../node/agentHostCommitOperationProvider.js';
|
||||
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
|
||||
|
||||
const sessionKey = 'agent:/session';
|
||||
const uncommittedChangesetUri = buildUncommittedChangesetUri(sessionKey);
|
||||
|
||||
const gitStateWithUncommittedChanges: ISessionGitState = {
|
||||
branchName: 'feature/test',
|
||||
uncommittedChanges: 1,
|
||||
};
|
||||
|
||||
suite('AgentHostCommitOperationContribution', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function createContribution(): AgentHostCommitOperationContribution {
|
||||
return disposables.add(new AgentHostCommitOperationContribution(
|
||||
disposables.add(new AgentHostStateManager(new NullLogService())),
|
||||
disposables.add(new InstantiationService()),
|
||||
));
|
||||
}
|
||||
|
||||
test('advertises commit on the uncommitted changeset when there are uncommitted changes', () => {
|
||||
const provider = createContribution();
|
||||
|
||||
const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: gitStateWithUncommittedChanges });
|
||||
|
||||
assert.deepStrictEqual(operations?.map(op => op.id), ['commit']);
|
||||
});
|
||||
|
||||
test('does not advertise commit without uncommitted changes or on the branch changeset', () => {
|
||||
const provider = createContribution();
|
||||
|
||||
const actual = [
|
||||
provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }),
|
||||
provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }),
|
||||
];
|
||||
|
||||
assert.deepStrictEqual(actual, [undefined, undefined]);
|
||||
});
|
||||
});
|
||||
@@ -330,6 +330,8 @@ suite('AgentHostGitService - worktree helpers (real git)', () => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-wt-'));
|
||||
const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });
|
||||
run('init', '-q', '-b', 'main');
|
||||
run('config', 'user.name', 't');
|
||||
run('config', 'user.email', 't@t');
|
||||
run('commit', '-q', '--allow-empty', '-m', 'initial');
|
||||
return tmpRoot!;
|
||||
}
|
||||
@@ -351,6 +353,31 @@ suite('AgentHostGitService - worktree helpers (real git)', () => {
|
||||
assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false);
|
||||
});
|
||||
|
||||
(hasGit ? test : test.skip)('commitAll stages tracked, staged and untracked changes and creates a commit', async () => {
|
||||
const dir = initRepo();
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(join(dir, 'tracked.txt'), 'before');
|
||||
cp.execFileSync('git', ['add', 'tracked.txt'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['commit', '-q', '-m', 'add tracked'], { cwd: dir, env, stdio: 'pipe' });
|
||||
|
||||
await fs.writeFile(join(dir, 'tracked.txt'), 'after');
|
||||
await fs.writeFile(join(dir, 'staged.txt'), 'staged');
|
||||
cp.execFileSync('git', ['add', 'staged.txt'], { cwd: dir, env, stdio: 'pipe' });
|
||||
await fs.writeFile(join(dir, 'untracked.txt'), 'untracked');
|
||||
|
||||
await svc!.commitAll(URI.file(dir), 'commit all changes');
|
||||
|
||||
const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: dir, env, encoding: 'utf8' }).trim();
|
||||
const lastMessage = cp.execFileSync('git', ['log', '-1', '--format=%s'], { cwd: dir, env, encoding: 'utf8' }).trim();
|
||||
const committedFiles = cp.execFileSync('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], { cwd: dir, env, encoding: 'utf8' }).trim().split(/\r?\n/g).sort();
|
||||
|
||||
assert.deepStrictEqual({ status, lastMessage, committedFiles }, {
|
||||
status: '',
|
||||
lastMessage: 'commit all changes',
|
||||
committedFiles: ['staged.txt', 'tracked.txt', 'untracked.txt'],
|
||||
});
|
||||
});
|
||||
|
||||
(hasGit ? test : test.skip)('addExistingWorktree attaches a worktree for an existing branch (no -b)', async () => {
|
||||
const dir = initRepo();
|
||||
cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
|
||||
@@ -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_COPILOT_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';
|
||||
@@ -885,6 +885,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,
|
||||
@@ -933,6 +936,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,
|
||||
@@ -1195,8 +1201,40 @@ suite('AgentService (node dispatcher)', () => {
|
||||
|
||||
const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' });
|
||||
|
||||
assert.deepStrictEqual(result, { authenticated: false });
|
||||
assert.strictEqual(copilotAgent.authenticateCalls.length, 0);
|
||||
assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: 'https://unknown.example.com' }), authenticateCalls: copilotAgent.authenticateCalls }, {
|
||||
result: { authenticated: false },
|
||||
token: undefined,
|
||||
authenticateCalls: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('stores GitHub Copilot token for operation handlers', async () => {
|
||||
service.registerProvider(copilotAgent);
|
||||
|
||||
const result = await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' });
|
||||
|
||||
assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported }), authenticateCalls: copilotAgent.authenticateCalls }, {
|
||||
result: { authenticated: true },
|
||||
token: 'copilot-token',
|
||||
authenticateCalls: [{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('stores tokens for the same resource by scopes', async () => {
|
||||
service.registerProvider(copilotAgent);
|
||||
|
||||
await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user'], token: 'read-token' });
|
||||
await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user', 'user:email'], token: 'profile-token' });
|
||||
|
||||
assert.deepStrictEqual({
|
||||
readToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user'] }),
|
||||
profileToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email', 'read:user'] }),
|
||||
supersetToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email'] }),
|
||||
}, {
|
||||
readToken: 'read-token',
|
||||
profileToken: 'profile-token',
|
||||
supersetToken: 'profile-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('fans out to every provider that owns the resource', async () => {
|
||||
|
||||
@@ -91,6 +91,9 @@ class TestAgentHostGitService implements IAgentHostGitService {
|
||||
async hasUncommittedChanges(workingDirectory: URI): Promise<boolean> {
|
||||
return this.dirtyWorkingDirectories.has(workingDirectory.fsPath);
|
||||
}
|
||||
async commitAll(): Promise<void> { }
|
||||
async hasUpstream(): Promise<boolean> { return false; }
|
||||
async pushBranch(): Promise<void> { }
|
||||
async getSessionGitState(): Promise<undefined> { return undefined; }
|
||||
async computeSessionFileDiffs(): Promise<undefined> { return undefined; }
|
||||
async showBlob(): Promise<undefined> { return undefined; }
|
||||
|
||||
@@ -27,6 +27,9 @@ class TestAgentHostGitService implements IAgentHostGitService {
|
||||
async removeWorktree(): Promise<void> { }
|
||||
async branchExists(): Promise<boolean> { return false; }
|
||||
async hasUncommittedChanges(): Promise<boolean> { return false; }
|
||||
async commitAll(): Promise<void> { }
|
||||
async hasUpstream(): Promise<boolean> { return false; }
|
||||
async pushBranch(): Promise<void> { }
|
||||
async getSessionGitState(): Promise<undefined> { return undefined; }
|
||||
async computeSessionFileDiffs(): Promise<undefined> { return undefined; }
|
||||
async showBlob(): Promise<undefined> { return undefined; }
|
||||
|
||||
@@ -128,6 +128,7 @@ class MockAgentService implements IAgentService {
|
||||
unsubscribe(_resource: URI, _clientId: string): void { }
|
||||
async shutdown(): Promise<void> { }
|
||||
async authenticate(_params: AuthenticateParams): Promise<AuthenticateResult> { return { authenticated: true }; }
|
||||
getAuthToken(): string | undefined { return undefined; }
|
||||
async resourceWrite(_params: ResourceWriteParams): Promise<ResourceWriteResult> { return {}; }
|
||||
async resourceList(uri: URI): Promise<ResourceListResult> {
|
||||
this.browsedUris.push(uri);
|
||||
@@ -167,6 +168,7 @@ class MockAgentService implements IAgentService {
|
||||
}
|
||||
async createTerminal(): Promise<void> { }
|
||||
async disposeTerminal(): Promise<void> { }
|
||||
async invokeChangesetOperation(): Promise<{}> { return {}; }
|
||||
|
||||
dispose(): void {
|
||||
this._onDidAction.dispose();
|
||||
|
||||
@@ -22,31 +22,45 @@ export class AgentHostAuthTokenCache {
|
||||
private readonly _lastTokens = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Record that we just sent `token` for `resource`, and return whether
|
||||
* this is a change from the last token sent. When `false`, callers
|
||||
* Record that we just sent `token` for `resource` and `scopes`, and return
|
||||
* whether this is a change from the last token sent. When `false`, callers
|
||||
* should skip the `authenticate` RPC.
|
||||
*/
|
||||
updateAndIsChanged(resource: string, token: string): boolean {
|
||||
const previous = this._lastTokens.get(resource);
|
||||
updateAndIsChanged(resource: string, scopes: readonly string[] | undefined, token: string): boolean {
|
||||
const key = this._key(resource, scopes);
|
||||
const previous = this._lastTokens.get(key);
|
||||
if (previous === token) {
|
||||
return false;
|
||||
}
|
||||
this._lastTokens.set(resource, token);
|
||||
this._lastTokens.set(key, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token for a specific resource, or all resources if
|
||||
* no argument is given. Call after a failed `authenticate` RPC (per-resource)
|
||||
* or when the agent host process restarts (all resources).
|
||||
* Clear the cached token for a specific resource/scope pair, a whole resource,
|
||||
* or all resources if no argument is given. Call after a failed `authenticate`
|
||||
* RPC or when the agent host process restarts.
|
||||
*/
|
||||
clear(resource?: string): void {
|
||||
clear(resource?: string, scopes?: readonly string[]): void {
|
||||
if (resource !== undefined) {
|
||||
this._lastTokens.delete(resource);
|
||||
if (scopes !== undefined) {
|
||||
this._lastTokens.delete(this._key(resource, scopes));
|
||||
return;
|
||||
}
|
||||
const prefix = `${resource}\x00`;
|
||||
for (const key of [...this._lastTokens.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._lastTokens.delete(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._lastTokens.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private _key(resource: string, scopes: readonly string[] | undefined): string {
|
||||
return `${resource}\x00${scopes ? [...new Set(scopes)].sort().join('\x00') : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +123,7 @@ export async function resolveTokenForResource(
|
||||
|
||||
export interface IAgentHostAuthenticateRequest {
|
||||
readonly resource: string;
|
||||
readonly scopes?: readonly string[];
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
@@ -131,10 +146,11 @@ export async function authenticateProtectedResources(
|
||||
for (const agent of agents) {
|
||||
for (const resource of agent.protectedResources ?? []) {
|
||||
const resourceUri = URI.parse(resource.resource);
|
||||
const scopes = resource.scopes_supported ?? [];
|
||||
const token = await resolveTokenForResource(
|
||||
resourceUri,
|
||||
resource.authorization_servers ?? [],
|
||||
resource.scopes_supported ?? [],
|
||||
scopes,
|
||||
options.authenticationService,
|
||||
options.logService,
|
||||
options.logPrefix,
|
||||
@@ -144,16 +160,16 @@ export async function authenticateProtectedResources(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.authTokenCache && !options.authTokenCache.updateAndIsChanged(resource.resource, token)) {
|
||||
if (options.authTokenCache && !options.authTokenCache.updateAndIsChanged(resource.resource, scopes, token)) {
|
||||
options.logService.trace(`${options.logPrefix} Auth token for ${resource.resource} unchanged; skipping authenticate RPC`);
|
||||
continue;
|
||||
}
|
||||
|
||||
options.logService.info(`${options.logPrefix} Authenticating for resource: ${resource.resource}`);
|
||||
try {
|
||||
await options.authenticate({ resource: resource.resource, token });
|
||||
await options.authenticate({ resource: resource.resource, scopes, token });
|
||||
} catch (err) {
|
||||
options.authTokenCache?.clear(resource.resource);
|
||||
options.authTokenCache?.clear(resource.resource, scopes);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -170,17 +186,18 @@ export async function resolveAuthenticationInteractively(
|
||||
): Promise<boolean> {
|
||||
for (const resource of protectedResources) {
|
||||
const resourceUri = URI.parse(resource.resource);
|
||||
const scopes = resource.scopes_supported ?? [];
|
||||
const token = await resolveTokenForResource(
|
||||
resourceUri,
|
||||
resource.authorization_servers ?? [],
|
||||
resource.scopes_supported ?? [],
|
||||
scopes,
|
||||
options.authenticationService,
|
||||
options.logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
if (token) {
|
||||
await options.authenticate({ resource: resource.resource, token });
|
||||
options.authTokenCache?.updateAndIsChanged(resource.resource, token);
|
||||
await options.authenticate({ resource: resource.resource, scopes, token });
|
||||
options.authTokenCache?.updateAndIsChanged(resource.resource, scopes, token);
|
||||
options.logService.info(`${options.logPrefix} Interactive authentication succeeded for ${resource.resource}`);
|
||||
return true;
|
||||
}
|
||||
@@ -192,14 +209,13 @@ export async function resolveAuthenticationInteractively(
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopes = [...(resource.scopes_supported ?? [])];
|
||||
const session = await options.authenticationService.createSession(providerId, scopes, {
|
||||
const session = await options.authenticationService.createSession(providerId, [...scopes], {
|
||||
activateImmediate: true,
|
||||
authorizationServer: serverUri,
|
||||
});
|
||||
|
||||
await options.authenticate({ resource: resource.resource, token: session.accessToken });
|
||||
options.authTokenCache?.updateAndIsChanged(resource.resource, session.accessToken);
|
||||
await options.authenticate({ resource: resource.resource, scopes, token: session.accessToken });
|
||||
options.authTokenCache?.updateAndIsChanged(resource.resource, scopes, session.accessToken);
|
||||
options.logService.info(`${options.logPrefix} Interactive authentication succeeded for ${resource.resource}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -123,39 +123,47 @@ suite('AgentHostAuthTokenCache', () => {
|
||||
|
||||
test('first token for a resource is reported as changed', () => {
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1'), true);
|
||||
});
|
||||
|
||||
test('repeating the same token for the same resource is reported as unchanged', () => {
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
cache.updateAndIsChanged('https://api.example.com', 'tok1');
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), false);
|
||||
cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1');
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1'), false);
|
||||
});
|
||||
|
||||
test('a different token for the same resource is reported as changed', () => {
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
cache.updateAndIsChanged('https://api.example.com', 'tok1');
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok2'), true);
|
||||
cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1');
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok2'), true);
|
||||
// And the new token is now the cached one.
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok2'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok2'), false);
|
||||
});
|
||||
|
||||
test('tokens for distinct scopes are tracked independently', () => {
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'read-token'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['write'], 'write-token'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'read-token'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['write'], 'write-token'), false);
|
||||
});
|
||||
|
||||
test('tokens for distinct resources are tracked independently', () => {
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', 'tok1'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', ['read'], 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1'), false);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', ['read'], 'tok1'), false);
|
||||
});
|
||||
|
||||
test('clear forgets every cached token', () => {
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
cache.updateAndIsChanged('https://api.example.com', 'tok1');
|
||||
cache.updateAndIsChanged('https://other.example.com', 'tok2');
|
||||
cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1');
|
||||
cache.updateAndIsChanged('https://other.example.com', ['read'], 'tok2');
|
||||
cache.clear();
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', 'tok2'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', ['read'], 'tok1'), true);
|
||||
assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', ['read'], 'tok2'), true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,7 +190,7 @@ suite('authenticateProtectedResources', () => {
|
||||
},
|
||||
});
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
const requests: { resource: string; token: string }[] = [];
|
||||
const requests: { resource: string; scopes?: readonly string[]; token: string }[] = [];
|
||||
const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[];
|
||||
|
||||
await authenticateProtectedResources(agents, {
|
||||
@@ -204,7 +212,7 @@ suite('authenticateProtectedResources', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, token: 'cached-token' }]);
|
||||
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, scopes: ['read'], token: 'cached-token' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,7 +243,7 @@ suite('resolveAuthenticationInteractively', () => {
|
||||
return { accessToken: 'new-token' };
|
||||
},
|
||||
});
|
||||
const requests: { resource: string; token: string }[] = [];
|
||||
const requests: { resource: string; scopes?: readonly string[]; token: string }[] = [];
|
||||
|
||||
const success = await resolveAuthenticationInteractively([protectedResource], {
|
||||
authTokenCache: new AgentHostAuthTokenCache(),
|
||||
@@ -248,7 +256,7 @@ suite('resolveAuthenticationInteractively', () => {
|
||||
});
|
||||
|
||||
assert.strictEqual(success, true);
|
||||
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, token: 'existing-token' }]);
|
||||
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, scopes: ['read'], token: 'existing-token' }]);
|
||||
assert.strictEqual(createSessionCalls, 0);
|
||||
});
|
||||
|
||||
@@ -258,7 +266,7 @@ suite('resolveAuthenticationInteractively', () => {
|
||||
getSessions: () => Promise.resolve([]),
|
||||
createSession: async () => ({ accessToken: 'new-token' }),
|
||||
});
|
||||
const requests: { resource: string; token: string }[] = [];
|
||||
const requests: { resource: string; scopes?: readonly string[]; token: string }[] = [];
|
||||
|
||||
const success = await resolveAuthenticationInteractively([protectedResource], {
|
||||
authTokenCache: new AgentHostAuthTokenCache(),
|
||||
@@ -271,6 +279,6 @@ suite('resolveAuthenticationInteractively', () => {
|
||||
});
|
||||
|
||||
assert.strictEqual(success, true);
|
||||
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, token: 'new-token' }]);
|
||||
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, scopes: ['read'], token: 'new-token' }]);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-7
@@ -216,9 +216,9 @@ class MockAgentHostService extends mock<IAgentHostService>() {
|
||||
this._rootStateOnDidChange.fire(state);
|
||||
}
|
||||
|
||||
public authenticateCalls: { resource: string; token: string }[] = [];
|
||||
override async authenticate(params: { resource: string; token: string }): Promise<{ authenticated: boolean }> {
|
||||
this.authenticateCalls.push({ resource: params.resource, token: params.token });
|
||||
public authenticateCalls: { resource: string; scopes?: readonly string[]; token: string }[] = [];
|
||||
override async authenticate(params: { resource: string; scopes?: readonly string[]; token: string }): Promise<{ authenticated: boolean }> {
|
||||
this.authenticateCalls.push({ resource: params.resource, scopes: params.scopes, token: params.token });
|
||||
return { authenticated: true };
|
||||
}
|
||||
override getSubscription<T>(_kind: StateComponents, resource: URI): IReference<IAgentSubscription<T>> {
|
||||
@@ -4881,14 +4881,14 @@ suite('AgentHostChatContribution', () => {
|
||||
// First rootState — kicks off the eager auth pass.
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [{ resource: 'https://api.github.com', token: 'tok-1' }]);
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [{ resource: 'https://api.github.com', scopes: ['read:user'], token: 'tok-1' }]);
|
||||
|
||||
// Repeated rootState changes with the same token must not re-fire authenticate.
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 1 });
|
||||
await timeout(0);
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [{ resource: 'https://api.github.com', token: 'tok-1' }]);
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [{ resource: 'https://api.github.com', scopes: ['read:user'], token: 'tok-1' }]);
|
||||
});
|
||||
|
||||
test('re-authenticates when token rotates, then dedupes again', async () => {
|
||||
@@ -4910,8 +4910,8 @@ suite('AgentHostChatContribution', () => {
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [
|
||||
{ resource: 'https://api.github.com', token: 'tok-1' },
|
||||
{ resource: 'https://api.github.com', token: 'tok-2' },
|
||||
{ resource: 'https://api.github.com', scopes: ['read:user'], token: 'tok-1' },
|
||||
{ resource: 'https://api.github.com', scopes: ['read:user'], token: 'tok-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,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 } });
|
||||
|
||||
@@ -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 { IRemoteWatchHandle } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js';
|
||||
import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult } from '../../../../platform/agentHost/common/state/sessionProtocol.js';
|
||||
@@ -216,6 +217,10 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA
|
||||
return this._requireClient().disposeTerminal(terminal);
|
||||
}
|
||||
|
||||
invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
|
||||
return this._requireClient().invokeChangesetOperation(params);
|
||||
}
|
||||
|
||||
resourceList(uri: URI): Promise<ResourceListResult> {
|
||||
return this._requireClient().resourceList(uri);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user