diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts index efd6a0461a197..12fe91fabace9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts @@ -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 { const promptFile = new ChatVariablesCollection(request.references).find(isPromptFile); if (!promptFile || !URI.isUri(promptFile.reference.value)) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 30bb414410ea7..f2b379c464941 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -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(); + private readonly contextForRequest = new Map(); /** * 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 | 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 | 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. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts index e9e898cab3082..5a1793878f08f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts @@ -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; + startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean, workspace: IWorkspaceInfo, agentName?: string): Promise; /** * 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 { + async startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean, workspace: IWorkspaceInfo, agentName?: string): Promise { 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(); 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 { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts index fc0f0503ac0cc..3817b06ad5313 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts @@ -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,40 +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(); - 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 - ); - - 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(); + 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, @@ -648,8 +607,9 @@ describe('ChatSessionInitializer', () => { 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(); }); }); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts index eca0089c739dc..dd9356818b6cc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts @@ -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() { declare readonly _serviceBrand: undefined; override handleRequestCompleted = vi.fn(async () => { }); + override setWorktreeProperties = vi.fn(async () => { }); } class TestCheckpointService extends mock() { @@ -31,6 +35,7 @@ class TestCheckpointService extends mock( class TestWorkspaceFolderService extends mock() { declare readonly _serviceBrand: undefined; override handleRequestCompleted = vi.fn(async () => { }); + override trackSessionWorkspaceFolder = vi.fn(async () => { }); } class TestPrDetectionService extends mock() { @@ -39,6 +44,16 @@ class TestPrDetectionService extends mock() { override handlePullRequestCreated = vi.fn(); } +class TestMetadataStore extends mock() { + declare readonly _serviceBrand: undefined; + override updateRequestDetails = vi.fn(async () => { }); +} + +class TestLogService extends mock() { + 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 { + 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); });