mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-21 02:01:43 -05:00
refactor(copilotcli): move worktree properties and metadata tracking to session request lifecycle (#308960)
refactor: move worktree properties and metadata tracking to session request lifecycle
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import type { SweCustomAgent } from '@github/copilot/sdk';
|
||||
import * as l10n from '@vscode/l10n';
|
||||
import * as vscode from 'vscode';
|
||||
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
|
||||
import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';
|
||||
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
|
||||
@@ -13,17 +14,13 @@ import { createServiceIdentifier } from '../../../util/common/services';
|
||||
import { DisposableStore, IReference } from '../../../util/vs/base/common/lifecycle';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection';
|
||||
import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore';
|
||||
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
|
||||
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
|
||||
import { FolderRepositoryInfo, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
|
||||
import { SessionIdForCLI } from '../copilotcli/common/utils';
|
||||
import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
|
||||
import { SessionIdForCLI } from '../copilotcli/common/utils';
|
||||
import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels } from '../copilotcli/node/copilotCli';
|
||||
import { ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
|
||||
import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
|
||||
import { buildMcpServerMappings, McpServerMappings } from '../copilotcli/node/mcpHandler';
|
||||
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
|
||||
|
||||
function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean {
|
||||
return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled);
|
||||
@@ -85,13 +82,10 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI
|
||||
constructor(
|
||||
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
|
||||
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
|
||||
@IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService,
|
||||
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
|
||||
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
|
||||
@ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels,
|
||||
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
|
||||
@IPromptsService private readonly promptsService: IPromptsService,
|
||||
@IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
) { }
|
||||
@@ -129,15 +123,6 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI
|
||||
return { session: undefined, isNewSession, model, agent, trusted };
|
||||
}
|
||||
this.logService.info(`Using Copilot CLI session: ${session.object.sessionId} (isNewSession: ${isNewSession}, isolationEnabled: ${isIsolationEnabled(workspaceInfo)}, workingDirectory: ${workingDirectory}, worktreePath: ${worktreeProperties?.worktreePath})`);
|
||||
if (isNewSession) {
|
||||
if (worktreeProperties) {
|
||||
void this.worktreeService.setWorktreeProperties(session.object.sessionId, worktreeProperties);
|
||||
}
|
||||
this.finalizeSessionCreation(session.object.sessionId, session.object.workspace);
|
||||
}
|
||||
|
||||
const modeInstructions = this.createModeInstructions(request);
|
||||
this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
|
||||
|
||||
disposables.add(session);
|
||||
disposables.add(session.object.attachStream(stream));
|
||||
@@ -198,14 +183,6 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI
|
||||
]);
|
||||
|
||||
const session = await this.sessionService.createSession({ workspace, agent, model: model?.model, reasoningEffort: model?.reasoningEffort, mcpServerMappings: options.mcpServerMappings }, token);
|
||||
const worktreeProperties = workspace.worktreeProperties;
|
||||
if (worktreeProperties) {
|
||||
void this.worktreeService.setWorktreeProperties(session.object.sessionId, worktreeProperties);
|
||||
}
|
||||
this.finalizeSessionCreation(session.object.sessionId, workspace);
|
||||
|
||||
const modeInstructions = this.createModeInstructions(request);
|
||||
this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
|
||||
|
||||
return { session, model, agent };
|
||||
}
|
||||
@@ -255,23 +232,6 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private finalizeSessionCreation(sessionId: string, workspace: IWorkspaceInfo): void {
|
||||
const workingDirectory = getWorkingDirectory(workspace);
|
||||
if (workingDirectory && !isIsolationEnabled(workspace)) {
|
||||
void this.workspaceFolderService.trackSessionWorkspaceFolder(sessionId, workingDirectory.fsPath, workspace.repositoryProperties);
|
||||
}
|
||||
}
|
||||
|
||||
private createModeInstructions(request: vscode.ChatRequest): StoredModeInstructions | undefined {
|
||||
return request.modeInstructions2 ? {
|
||||
uri: request.modeInstructions2.uri?.toString(),
|
||||
name: request.modeInstructions2.name,
|
||||
content: request.modeInstructions2.content,
|
||||
metadata: request.modeInstructions2.metadata,
|
||||
isBuiltin: request.modeInstructions2.isBuiltin,
|
||||
} : undefined;
|
||||
}
|
||||
|
||||
private async getPromptInfoFromRequest(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise<ParsedPromptFile | undefined> {
|
||||
const promptFile = new ChatVariablesCollection(request.references).find(isPromptFile);
|
||||
if (!promptFile || !URI.isUri(promptFile.reference.value)) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import type { Attachment, SessionOptions } from '@github/copilot/sdk';
|
||||
import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
|
||||
import * as l10n from '@vscode/l10n';
|
||||
import * as vscode from 'vscode';
|
||||
import { ChatExtendedRequestHandler, ChatRequestTurn2, Uri } from 'vscode';
|
||||
@@ -47,10 +47,10 @@ import { ICopilotCLIChatSessionInitializer, SessionInitOptions } from './copilot
|
||||
import { convertReferenceToVariable } from './copilotCLIPromptReferences';
|
||||
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
|
||||
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
|
||||
import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl';
|
||||
import { IPullRequestDetectionService } from './pullRequestDetectionService';
|
||||
import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';
|
||||
import { ISessionRequestLifecycle } from './sessionRequestLifecycle';
|
||||
import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl';
|
||||
|
||||
/**
|
||||
* ODO:
|
||||
@@ -580,7 +580,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
|
||||
return this.handleRequest.bind(this);
|
||||
}
|
||||
|
||||
private readonly contextForRequest = new Map<string, { prompt: string; attachments: Attachment[] }>();
|
||||
private readonly contextForRequest = new Map<string, { prompt: string; attachments: Attachment[]; agent?: string }>();
|
||||
|
||||
/**
|
||||
* Outer request handler that supports *yielding* for session steering.
|
||||
@@ -733,14 +733,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
|
||||
const selectedOptions = getSelectedSessionOptions(chatSessionContext.inputState);
|
||||
const sessionResult = await this.getOrCreateSession(request, chatSessionContext.chatSessionItem.resource, { ...selectedOptions, newBranch: branchNamePromise, stream }, disposables, token);
|
||||
({ session } = sessionResult);
|
||||
const { model } = sessionResult;
|
||||
const { model, agent } = sessionResult;
|
||||
if (!session || token.isCancellationRequested) {
|
||||
return {};
|
||||
}
|
||||
|
||||
sdkSessionId = session.object.sessionId;
|
||||
|
||||
await this.sessionRequestLifecycle.startRequest(sdkSessionId, request, context.history.length === 0);
|
||||
await this.sessionRequestLifecycle.startRequest(sdkSessionId, request, context.history.length === 0, session.object.workspace, agent?.name ?? this.contextForRequest.get(session.object.sessionId)?.agent);
|
||||
|
||||
if (request.command === 'delegate') {
|
||||
await this.handleDelegationToCloud(session.object, request, context, stream, token);
|
||||
@@ -769,18 +769,18 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateSession(request: vscode.ChatRequest, chatResource: vscode.Uri, options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; trusted: boolean }> {
|
||||
private async getOrCreateSession(request: vscode.ChatRequest, chatResource: vscode.Uri, options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }> {
|
||||
const result = await this.sessionInitializer.getOrCreateSession(request, chatResource, options, disposables, token);
|
||||
const { session, isNewSession, model, trusted } = result;
|
||||
const { session, isNewSession, model, agent, trusted } = result;
|
||||
if (!session || token.isCancellationRequested) {
|
||||
return { session: undefined, isNewSession, model, trusted };
|
||||
return { session: undefined, isNewSession, model, agent, trusted };
|
||||
}
|
||||
|
||||
if (isNewSession) {
|
||||
this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: session.object.sessionId });
|
||||
}
|
||||
|
||||
return { session, isNewSession, model, trusted };
|
||||
return { session, isNewSession, model, agent, trusted };
|
||||
}
|
||||
|
||||
private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {
|
||||
@@ -835,7 +835,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
|
||||
const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token);
|
||||
|
||||
const mcpServerMappings = buildMcpServerMappings(request.tools);
|
||||
const { session, model } = await this.sessionInitializer.createDelegatedSession(request, workspaceInfo, { mcpServerMappings }, token);
|
||||
const { session, model, agent } = await this.sessionInitializer.createDelegatedSession(request, workspaceInfo, { mcpServerMappings }, token);
|
||||
|
||||
if (summary) {
|
||||
const summaryRef = await this.chatDelegationSummaryService.trackSummaryUsage(session.object.sessionId, summary);
|
||||
if (summaryRef) {
|
||||
@@ -844,7 +845,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
|
||||
}
|
||||
|
||||
try {
|
||||
this.contextForRequest.set(session.object.sessionId, { prompt, attachments });
|
||||
this.contextForRequest.set(session.object.sessionId, { prompt, attachments, agent: agent?.name });
|
||||
// this.sessionItemProvider.notifySessionsChange();
|
||||
// TODO @DonJayamanne I don't think we need to refresh the list of session here just yet, or perhaps we do,
|
||||
// Same as getOrCreate session, we need a dummy title or the initial prompt to show in the sessions list.
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { createServiceIdentifier } from '../../../util/common/services';
|
||||
import { Disposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore';
|
||||
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
|
||||
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
|
||||
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
|
||||
@@ -17,9 +19,10 @@ export interface ISessionRequestLifecycle {
|
||||
|
||||
/**
|
||||
* Begin tracking a request for a session. Creates a baseline checkpoint
|
||||
* if this is the first request in the session.
|
||||
* if this is the first request in the session. Records request details
|
||||
* (agent, mode instructions) in the metadata store.
|
||||
*/
|
||||
startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean): Promise<void>;
|
||||
startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean, workspace: IWorkspaceInfo, agentName?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Finalize a request: commit worktree changes, create checkpoints, detect
|
||||
@@ -59,18 +62,39 @@ export class SessionRequestLifecycle extends Disposable implements ISessionReque
|
||||
@IChatSessionWorktreeCheckpointService private readonly checkpointService: IChatSessionWorktreeCheckpointService,
|
||||
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
|
||||
@IPullRequestDetectionService private readonly prDetectionService: IPullRequestDetectionService,
|
||||
@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean): Promise<void> {
|
||||
async startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean, workspace: IWorkspaceInfo, agentName?: string): Promise<void> {
|
||||
if (isFirstRequest) {
|
||||
await this.checkpointService.handleRequest(sessionId);
|
||||
if (workspace.worktreeProperties) {
|
||||
void this.worktreeService.setWorktreeProperties(sessionId, workspace.worktreeProperties);
|
||||
}
|
||||
const workingDirectory = getWorkingDirectory(workspace);
|
||||
if (workingDirectory && !isIsolationEnabled(workspace)) {
|
||||
void this.workspaceFolderService.trackSessionWorkspaceFolder(sessionId, workingDirectory.fsPath, workspace.repositoryProperties);
|
||||
}
|
||||
}
|
||||
|
||||
const modeInstructions: StoredModeInstructions | undefined = request.modeInstructions2 ? {
|
||||
uri: request.modeInstructions2.uri?.toString(),
|
||||
name: request.modeInstructions2.name,
|
||||
content: request.modeInstructions2.content,
|
||||
metadata: request.modeInstructions2.metadata,
|
||||
isBuiltin: request.modeInstructions2.isBuiltin,
|
||||
} : undefined;
|
||||
this.metadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agentName ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
|
||||
|
||||
const requests = this.pendingRequestBySession.get(sessionId) ?? new Set<vscode.ChatRequest>();
|
||||
requests.add(request);
|
||||
this.pendingRequestBySession.set(sessionId, requests);
|
||||
|
||||
if (isFirstRequest) {
|
||||
await this.checkpointService.handleRequest(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async endRequest(sessionId: string, request: vscode.ChatRequest, session: SessionCompletionInfo, token: vscode.CancellationToken): Promise<void> {
|
||||
|
||||
@@ -173,13 +173,10 @@ function createInitializer(overrides?: {
|
||||
const initializer = new CopilotCLIChatSessionInitializer(
|
||||
sessionService,
|
||||
folderRepoManager,
|
||||
worktreeService,
|
||||
workspaceFolderService,
|
||||
workspaceService,
|
||||
models,
|
||||
agents,
|
||||
promptsService,
|
||||
metadataStore,
|
||||
logService,
|
||||
configurationService,
|
||||
);
|
||||
@@ -507,7 +504,7 @@ describe('ChatSessionInitializer', () => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('sets worktree properties for new session with worktree', async () => {
|
||||
it('does not set worktree properties (moved to startRequest)', async () => {
|
||||
const sessionService = new TestSessionService();
|
||||
sessionService.isNewSessionId.mockReturnValue(true);
|
||||
const folderRepoManager = new TestFolderRepositoryManager();
|
||||
@@ -534,14 +531,11 @@ describe('ChatSessionInitializer', () => {
|
||||
disposables, CancellationToken.None
|
||||
);
|
||||
|
||||
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
|
||||
'test-session-id',
|
||||
expect.objectContaining({ branchName: 'copilot/test' })
|
||||
);
|
||||
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('tracks workspace folder for new non-isolated session', async () => {
|
||||
it('does not track workspace folder (moved to startRequest)', async () => {
|
||||
const sessionService = new TestSessionService();
|
||||
sessionService.isNewSessionId.mockReturnValue(true);
|
||||
const { initializer, workspaceFolderService } = createInitializer({ sessionService });
|
||||
@@ -552,11 +546,11 @@ describe('ChatSessionInitializer', () => {
|
||||
disposables, CancellationToken.None
|
||||
);
|
||||
|
||||
expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();
|
||||
expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('records request metadata', async () => {
|
||||
it('does not record request metadata (moved to startRequest)', async () => {
|
||||
const { initializer, metadataStore } = createInitializer();
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
@@ -565,19 +559,14 @@ describe('ChatSessionInitializer', () => {
|
||||
disposables, CancellationToken.None
|
||||
);
|
||||
|
||||
expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ vscodeRequestId: 'request-1' })
|
||||
])
|
||||
);
|
||||
expect(metadataStore.updateRequestDetails).not.toHaveBeenCalled();
|
||||
disposables.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDelegatedSession', () => {
|
||||
it('creates session and finalizes', async () => {
|
||||
const { initializer, sessionService, workspaceFolderService, metadataStore } = createInitializer();
|
||||
it('creates session and resolves model', async () => {
|
||||
const { initializer, sessionService } = createInitializer();
|
||||
const workspace: IWorkspaceInfo = {
|
||||
folder: URI.file('/workspace') as unknown as vscode.Uri,
|
||||
repository: undefined,
|
||||
@@ -594,12 +583,10 @@ describe('ChatSessionInitializer', () => {
|
||||
expect(result.session).toBeDefined();
|
||||
expect(result.model).toEqual(expect.objectContaining({ model: 'resolved-model' }));
|
||||
expect(sessionService.createSession).toHaveBeenCalled();
|
||||
expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();
|
||||
expect(metadataStore.updateRequestDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets worktree properties when workspace has worktree', async () => {
|
||||
const { initializer, worktreeService } = createInitializer();
|
||||
it('does not set worktree properties or track workspace folder (moved to startRequest)', async () => {
|
||||
const { initializer, worktreeService, workspaceFolderService, metadataStore } = createInitializer();
|
||||
const workspace: IWorkspaceInfo = {
|
||||
folder: URI.file('/workspace') as unknown as vscode.Uri,
|
||||
repository: URI.file('/repo') as unknown as vscode.Uri,
|
||||
@@ -620,36 +607,9 @@ describe('ChatSessionInitializer', () => {
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
|
||||
'test-session-id',
|
||||
expect.objectContaining({ branchName: 'copilot/test' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not track workspace folder for isolated session', async () => {
|
||||
const { initializer, workspaceFolderService } = createInitializer();
|
||||
const workspace: IWorkspaceInfo = {
|
||||
folder: URI.file('/workspace') as unknown as vscode.Uri,
|
||||
repository: URI.file('/repo') as unknown as vscode.Uri,
|
||||
repositoryProperties: undefined,
|
||||
worktree: URI.file('/worktree') as unknown as vscode.Uri,
|
||||
worktreeProperties: {
|
||||
version: 2,
|
||||
baseCommit: 'abc',
|
||||
baseBranchName: 'main',
|
||||
branchName: 'copilot/test',
|
||||
repositoryPath: '/repo',
|
||||
worktreePath: '/worktree',
|
||||
},
|
||||
};
|
||||
|
||||
await initializer.createDelegatedSession(
|
||||
makeRequest(), workspace, { mcpServerMappings: new Map() },
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Isolated session (has worktreeProperties) should NOT track workspace folder
|
||||
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
|
||||
expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();
|
||||
expect(metadataStore.updateRequestDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,21 +5,25 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type * as vscode from 'vscode';
|
||||
import { ILogService } from '../../../../platform/log/common/logService';
|
||||
import { mock } from '../../../../util/common/test/simpleMock';
|
||||
import { Event } from '../../../../util/vs/base/common/event';
|
||||
import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { ChatSessionStatus } from '../../../../vscodeTypes';
|
||||
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
|
||||
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
|
||||
import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService';
|
||||
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
|
||||
import { IWorkspaceInfo } from '../../common/workspaceInfo';
|
||||
import { IPullRequestDetectionService } from '../pullRequestDetectionService';
|
||||
import { SessionRequestLifecycle, SessionCompletionInfo } from '../sessionRequestLifecycle';
|
||||
import { SessionCompletionInfo, SessionRequestLifecycle } from '../sessionRequestLifecycle';
|
||||
|
||||
// ─── Test Helpers ────────────────────────────────────────────────
|
||||
|
||||
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
override handleRequestCompleted = vi.fn(async () => { });
|
||||
override setWorktreeProperties = vi.fn(async () => { });
|
||||
}
|
||||
|
||||
class TestCheckpointService extends mock<IChatSessionWorktreeCheckpointService>() {
|
||||
@@ -31,6 +35,7 @@ class TestCheckpointService extends mock<IChatSessionWorktreeCheckpointService>(
|
||||
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
override handleRequestCompleted = vi.fn(async () => { });
|
||||
override trackSessionWorkspaceFolder = vi.fn(async () => { });
|
||||
}
|
||||
|
||||
class TestPrDetectionService extends mock<IPullRequestDetectionService>() {
|
||||
@@ -39,6 +44,16 @@ class TestPrDetectionService extends mock<IPullRequestDetectionService>() {
|
||||
override handlePullRequestCreated = vi.fn();
|
||||
}
|
||||
|
||||
class TestMetadataStore extends mock<IChatSessionMetadataStore>() {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
override updateRequestDetails = vi.fn(async () => { });
|
||||
}
|
||||
|
||||
class TestLogService extends mock<ILogService>() {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
override error = vi.fn();
|
||||
}
|
||||
|
||||
function makeRequest(id: string = 'req-1'): vscode.ChatRequest {
|
||||
return { id } as unknown as vscode.ChatRequest;
|
||||
}
|
||||
@@ -82,6 +97,32 @@ function makeToken(cancelled: boolean = false): vscode.CancellationToken {
|
||||
return { isCancellationRequested: cancelled, onCancellationRequested: vi.fn() } as unknown as vscode.CancellationToken;
|
||||
}
|
||||
|
||||
function makeWorkspace(overrides?: Partial<IWorkspaceInfo>): IWorkspaceInfo {
|
||||
return {
|
||||
folder: URI.file('/workspace') as unknown as vscode.Uri,
|
||||
repository: undefined,
|
||||
repositoryProperties: undefined,
|
||||
worktree: undefined,
|
||||
worktreeProperties: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIsolatedWorkspace(): IWorkspaceInfo {
|
||||
return makeWorkspace({
|
||||
repository: URI.file('/repo') as unknown as vscode.Uri,
|
||||
worktree: URI.file('/worktree') as unknown as vscode.Uri,
|
||||
worktreeProperties: {
|
||||
version: 2,
|
||||
baseCommit: 'abc',
|
||||
baseBranchName: 'main',
|
||||
branchName: 'copilot/test',
|
||||
repositoryPath: '/repo',
|
||||
worktreePath: '/worktree',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────
|
||||
|
||||
describe('SessionRequestLifecycle', () => {
|
||||
@@ -89,6 +130,8 @@ describe('SessionRequestLifecycle', () => {
|
||||
let checkpointService: TestCheckpointService;
|
||||
let workspaceFolderService: TestWorkspaceFolderService;
|
||||
let prDetectionService: TestPrDetectionService;
|
||||
let metadataStore: TestMetadataStore;
|
||||
let logService: TestLogService;
|
||||
let handler: SessionRequestLifecycle;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -97,26 +140,93 @@ describe('SessionRequestLifecycle', () => {
|
||||
checkpointService = new TestCheckpointService();
|
||||
workspaceFolderService = new TestWorkspaceFolderService();
|
||||
prDetectionService = new TestPrDetectionService();
|
||||
metadataStore = new TestMetadataStore();
|
||||
logService = new TestLogService();
|
||||
handler = new SessionRequestLifecycle(
|
||||
worktreeService,
|
||||
checkpointService,
|
||||
workspaceFolderService,
|
||||
prDetectionService,
|
||||
metadataStore,
|
||||
logService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('startRequest', () => {
|
||||
it('creates baseline checkpoint on first request', async () => {
|
||||
const request = makeRequest();
|
||||
await handler.startRequest('session-1', request, true);
|
||||
await handler.startRequest('session-1', request, true, makeWorkspace());
|
||||
expect(checkpointService.handleRequest).toHaveBeenCalledWith('session-1');
|
||||
});
|
||||
|
||||
it('skips baseline checkpoint on subsequent requests', async () => {
|
||||
const request = makeRequest();
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
expect(checkpointService.handleRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records request metadata with modeInstructions', async () => {
|
||||
const request = makeRequest();
|
||||
(request as any).modeInstructions2 = {
|
||||
name: 'test',
|
||||
content: 'instructions',
|
||||
};
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent');
|
||||
|
||||
expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
[{
|
||||
vscodeRequestId: 'req-1',
|
||||
agentId: 'test-agent',
|
||||
modeInstructions: expect.objectContaining({ name: 'test', content: 'instructions' }),
|
||||
}]
|
||||
);
|
||||
});
|
||||
|
||||
it('records metadata without modeInstructions when request has no modeInstructions2', async () => {
|
||||
const request = makeRequest();
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent');
|
||||
|
||||
expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
[{
|
||||
vscodeRequestId: 'req-1',
|
||||
agentId: 'test-agent',
|
||||
modeInstructions: undefined,
|
||||
}]
|
||||
);
|
||||
});
|
||||
|
||||
it('sets worktree properties on first request with worktree', async () => {
|
||||
const workspace = makeIsolatedWorkspace();
|
||||
await handler.startRequest('session-1', makeRequest(), true, workspace);
|
||||
|
||||
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
expect.objectContaining({ branchName: 'copilot/test' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set worktree properties on subsequent requests', async () => {
|
||||
const workspace = makeIsolatedWorkspace();
|
||||
await handler.startRequest('session-1', makeRequest(), false, workspace);
|
||||
|
||||
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tracks workspace folder for non-isolated session on first request', async () => {
|
||||
const workspace = makeWorkspace();
|
||||
await handler.startRequest('session-1', makeRequest(), true, workspace);
|
||||
|
||||
expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not track workspace folder for isolated session', async () => {
|
||||
const workspace = makeIsolatedWorkspace();
|
||||
await handler.startRequest('session-1', makeRequest(), true, workspace);
|
||||
|
||||
expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('endRequest', () => {
|
||||
@@ -124,7 +234,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeIsolatedSession();
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken());
|
||||
|
||||
expect(worktreeService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
|
||||
@@ -136,7 +246,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeSession(); // non-isolated, has folder
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken());
|
||||
|
||||
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
|
||||
@@ -148,7 +258,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeSession({ status: ChatSessionStatus.InProgress });
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken());
|
||||
|
||||
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
|
||||
@@ -160,7 +270,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeSession({ status: undefined });
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken());
|
||||
|
||||
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
|
||||
@@ -179,7 +289,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken());
|
||||
|
||||
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
|
||||
@@ -193,8 +303,8 @@ describe('SessionRequestLifecycle', () => {
|
||||
const req2 = makeRequest('req-2');
|
||||
const session = makeSession();
|
||||
|
||||
await handler.startRequest('session-1', req1, false);
|
||||
await handler.startRequest('session-1', req2, false);
|
||||
await handler.startRequest('session-1', req1, false, makeWorkspace());
|
||||
await handler.startRequest('session-1', req2, false, makeWorkspace());
|
||||
|
||||
// First request completes — should defer (2 pending)
|
||||
await handler.endRequest('session-1', req1, session, makeToken());
|
||||
@@ -212,7 +322,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeSession();
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken(true));
|
||||
|
||||
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
|
||||
@@ -224,7 +334,7 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeSession();
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', request, session, makeToken());
|
||||
|
||||
// PR detection is fire-and-forget; wait for microtask
|
||||
@@ -237,13 +347,13 @@ describe('SessionRequestLifecycle', () => {
|
||||
const request = makeRequest();
|
||||
const session = makeSession();
|
||||
|
||||
await handler.startRequest('session-1', request, false);
|
||||
await handler.startRequest('session-1', request, false, makeWorkspace());
|
||||
await expect(handler.endRequest('session-1', request, session, makeToken())).rejects.toThrow('commit failed');
|
||||
|
||||
// After the error, a new request for the same session should proceed normally
|
||||
workspaceFolderService.handleRequestCompleted.mockResolvedValue();
|
||||
const req2 = makeRequest('req-2');
|
||||
await handler.startRequest('session-1', req2, false);
|
||||
await handler.startRequest('session-1', req2, false, makeWorkspace());
|
||||
await handler.endRequest('session-1', req2, session, makeToken());
|
||||
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user