Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/chatSessionInitializer.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { SweCustomAgent } from '@github/copilot/sdk';6import { beforeEach, describe, expect, it, vi } from 'vitest';7import type * as vscode from 'vscode';8import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';9import { ILogService } from '../../../../../platform/log/common/logService';10import { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';11import { IWorkspaceService, NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';12import { mock } from '../../../../../util/common/test/simpleMock';13import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';14import { DisposableStore, IReference } from '../../../../../util/vs/base/common/lifecycle';15import { URI } from '../../../../../util/vs/base/common/uri';16import { IChatSessionMetadataStore } from '../../../common/chatSessionMetadataStore';17import { IChatSessionWorkspaceFolderService } from '../../../common/chatSessionWorkspaceFolderService';18import { IChatSessionWorktreeService } from '../../../common/chatSessionWorktreeService';19import { FolderRepositoryInfo, IFolderRepositoryManager, IsolationMode } from '../../../common/folderRepositoryManager';20import { IWorkspaceInfo } from '../../../common/workspaceInfo';21import { ICopilotCLIAgents, ICopilotCLIModels } from '../../../copilotcli/node/copilotCli';22import { ICopilotCLISession } from '../../../copilotcli/node/copilotcliSession';23import { ICopilotCLISessionService } from '../../../copilotcli/node/copilotcliSessionService';24import { CopilotCLIChatSessionInitializer } from '../copilotCLIChatSessionInitializer';2526// ─── Test Helpers ────────────────────────────────────────────────2728class TestSessionService extends mock<ICopilotCLISessionService>() {29declare readonly _serviceBrand: undefined;30override isNewSessionId = vi.fn(() => true);31override createSession = vi.fn(async (): Promise<IReference<ICopilotCLISession>> => ({32object: makeSessionObject(),33dispose: vi.fn(),34}));35override getSession = vi.fn(async (): Promise<IReference<ICopilotCLISession> | undefined> => ({36object: makeSessionObject(),37dispose: vi.fn(),38}));39}4041class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {42declare readonly _serviceBrand: undefined;43override initializeFolderRepository = vi.fn(async (): Promise<FolderRepositoryInfo> => ({44folder: URI.file('/workspace') as unknown as vscode.Uri,45repository: undefined,46repositoryProperties: undefined,47worktree: undefined,48worktreeProperties: undefined,49trusted: true,50}));51override getFolderRepository = vi.fn(async (): Promise<FolderRepositoryInfo> => ({52folder: URI.file('/workspace') as unknown as vscode.Uri,53repository: undefined,54repositoryProperties: undefined,55worktree: undefined,56worktreeProperties: undefined,57trusted: true,58}));59}6061class TestWorktreeService extends mock<IChatSessionWorktreeService>() {62declare readonly _serviceBrand: undefined;63override setWorktreeProperties = vi.fn(async () => { });64}6566class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {67declare readonly _serviceBrand: undefined;68override trackSessionWorkspaceFolder = vi.fn(async () => { });69}7071class TestModels extends mock<ICopilotCLIModels>() {72declare readonly _serviceBrand: undefined;73override resolveModel = vi.fn(async (id: string) => id === 'known-model' ? 'resolved-model' : undefined);74override getDefaultModel = vi.fn(async () => 'default-model');75}7677class TestAgents extends mock<ICopilotCLIAgents>() {78declare readonly _serviceBrand: undefined;79override resolveAgent = vi.fn(async (): Promise<SweCustomAgent | undefined> => undefined);80}8182class TestPromptsService extends mock<IPromptsService>() {83declare readonly _serviceBrand: undefined;84override parseFile = vi.fn(async () => ({ uri: URI.file('/test.prompt'), header: undefined, body: undefined }));85}8687class TestMetadataStore extends mock<IChatSessionMetadataStore>() {88declare readonly _serviceBrand: undefined;89override updateRequestDetails = vi.fn(async () => { });90}9192class TestConfigurationService extends mock<IConfigurationService>() {93declare readonly _serviceBrand: undefined;94override getConfig = vi.fn(() => undefined as any);95}9697class TestLogService extends mock<ILogService>() {98declare readonly _serviceBrand: undefined;99override trace = vi.fn();100override debug = vi.fn();101override info = vi.fn();102override error = vi.fn();103}104105function makeSessionObject(overrides?: Partial<ICopilotCLISession>): ICopilotCLISession {106return {107sessionId: 'test-session-id',108workspace: {109folder: URI.file('/workspace') as unknown as vscode.Uri,110repository: undefined,111repositoryProperties: undefined,112worktree: undefined,113worktreeProperties: undefined,114},115attachStream: vi.fn(() => ({ dispose: vi.fn() })),116setPermissionLevel: vi.fn(),117dispose: vi.fn(),118...overrides,119} as unknown as ICopilotCLISession;120}121122function makeRequest(overrides?: Partial<vscode.ChatRequest>): vscode.ChatRequest {123return {124id: 'request-1',125prompt: 'hello',126model: { id: 'known-model' },127references: [],128tools: [],129toolInvocationToken: {} as vscode.ChatParticipantToolToken,130permissionLevel: 'full',131modeInstructions2: undefined,132...overrides,133} as unknown as vscode.ChatRequest;134}135136function makeStream(): vscode.ChatResponseStream {137return {138warning: vi.fn(),139markdown: vi.fn(),140} as unknown as vscode.ChatResponseStream;141}142143function makeChatResource(sessionId: string = 'untitled:new-session'): vscode.Uri {144return URI.from({ scheme: 'copilotcli', path: `/${sessionId}` }) as unknown as vscode.Uri;145}146147function createInitializer(overrides?: {148sessionService?: TestSessionService;149folderRepoManager?: TestFolderRepositoryManager;150worktreeService?: TestWorktreeService;151workspaceFolderService?: TestWorkspaceFolderService;152workspaceService?: IWorkspaceService;153models?: TestModels;154agents?: TestAgents;155promptsService?: TestPromptsService;156metadataStore?: TestMetadataStore;157logService?: TestLogService;158configurationService?: TestConfigurationService;159}) {160const sessionService = overrides?.sessionService ?? new TestSessionService();161const folderRepoManager = overrides?.folderRepoManager ?? new TestFolderRepositoryManager();162const worktreeService = overrides?.worktreeService ?? new TestWorktreeService();163const workspaceFolderService = overrides?.workspaceFolderService ?? new TestWorkspaceFolderService();164const workspaceService = overrides?.workspaceService ?? new NullWorkspaceService([URI.file('/workspace')]);165const models = overrides?.models ?? new TestModels();166const agents = overrides?.agents ?? new TestAgents();167const promptsService = overrides?.promptsService ?? new TestPromptsService();168const metadataStore = overrides?.metadataStore ?? new TestMetadataStore();169const logService = overrides?.logService ?? new TestLogService();170const configurationService = overrides?.configurationService ?? new TestConfigurationService();171172const initializer = new CopilotCLIChatSessionInitializer(173sessionService,174folderRepoManager,175workspaceService,176models,177agents,178promptsService,179logService,180configurationService,181);182183return { initializer, sessionService, folderRepoManager, worktreeService, workspaceFolderService, models, agents, promptsService, metadataStore, logService, configurationService };184}185186// ─── Tests ───────────────────────────────────────────────────────187188describe('ChatSessionInitializer', () => {189beforeEach(() => {190vi.restoreAllMocks();191});192193describe('resolveModelId', () => {194it('returns resolved model from request.model.id', async () => {195const { initializer } = createInitializer();196const request = makeRequest({ model: { id: 'known-model' } } as Partial<vscode.ChatRequest>);197const result = await initializer.resolveModel(request, CancellationToken.None);198expect(result).toEqual(expect.objectContaining({ model: 'resolved-model' }));199});200201it('falls back to default model when request model is not resolvable', async () => {202const { initializer } = createInitializer();203const request = makeRequest({ model: { id: 'unknown-model' } } as Partial<vscode.ChatRequest>);204const result = await initializer.resolveModel(request, CancellationToken.None);205expect(result).toEqual(expect.objectContaining({ model: 'default-model' }));206});207208it('returns default model when request is undefined', async () => {209const { initializer } = createInitializer();210const result = await initializer.resolveModel(undefined, CancellationToken.None);211expect(result).toEqual(expect.objectContaining({ model: 'default-model' }));212});213214it('returns default model when request has no model', async () => {215const { initializer } = createInitializer();216const request = makeRequest({ model: undefined } as Partial<vscode.ChatRequest>);217const result = await initializer.resolveModel(request, CancellationToken.None);218expect(result).toEqual(expect.objectContaining({ model: 'default-model' }));219});220});221222describe('resolveAgent', () => {223it('returns undefined when request has no modeInstructions2', async () => {224const { initializer } = createInitializer();225const request = makeRequest({ modeInstructions2: undefined } as Partial<vscode.ChatRequest>);226const result = await initializer.resolveAgent(request, CancellationToken.None);227expect(result).toBeUndefined();228});229230it('returns undefined when request is undefined', async () => {231const { initializer } = createInitializer();232const result = await initializer.resolveAgent(undefined, CancellationToken.None);233expect(result).toBeUndefined();234});235236it('resolves agent by URI when modeInstructions2 has uri', async () => {237const agents = new TestAgents();238const fakeAgent = { name: 'test-agent' } as SweCustomAgent;239agents.resolveAgent.mockResolvedValue(fakeAgent);240const { initializer } = createInitializer({ agents });241242const request = makeRequest({243modeInstructions2: {244uri: URI.file('/agent.md') as unknown as vscode.Uri,245name: 'test-agent',246content: '',247toolReferences: [],248},249} as Partial<vscode.ChatRequest>);250251const result = await initializer.resolveAgent(request, CancellationToken.None);252expect(result).toBe(fakeAgent);253expect(agents.resolveAgent).toHaveBeenCalledWith(URI.file('/agent.md').toString());254});255256it('resolves agent by name when modeInstructions2 has no uri', async () => {257const agents = new TestAgents();258const fakeAgent = { name: 'test-agent' } as SweCustomAgent;259agents.resolveAgent.mockResolvedValue(fakeAgent);260const { initializer } = createInitializer({ agents });261262const request = makeRequest({263modeInstructions2: {264uri: undefined,265name: 'test-agent',266content: '',267toolReferences: [],268},269} as Partial<vscode.ChatRequest>);270271const result = await initializer.resolveAgent(request, CancellationToken.None);272expect(result).toBe(fakeAgent);273expect(agents.resolveAgent).toHaveBeenCalledWith('test-agent');274});275276it('overrides agent tools when modeInstructions2 provides toolReferences', async () => {277const agents = new TestAgents();278const fakeAgent = { name: 'test-agent', tools: [] } as unknown as SweCustomAgent;279agents.resolveAgent.mockResolvedValue(fakeAgent);280const { initializer } = createInitializer({ agents });281282const request = makeRequest({283modeInstructions2: {284uri: undefined,285name: 'test-agent',286content: '',287toolReferences: [{ name: 'tool-a' }, { name: 'tool-b' }],288},289} as Partial<vscode.ChatRequest>);290291const result = await initializer.resolveAgent(request, CancellationToken.None);292expect(result!.tools).toEqual(['tool-a', 'tool-b']);293});294295it('returns undefined when agent cannot be resolved', async () => {296const agents = new TestAgents();297agents.resolveAgent.mockResolvedValue(undefined);298const { initializer } = createInitializer({ agents });299300const request = makeRequest({301modeInstructions2: {302uri: undefined,303name: 'unknown-agent',304content: '',305toolReferences: [],306},307} as Partial<vscode.ChatRequest>);308309const result = await initializer.resolveAgent(request, CancellationToken.None);310expect(result).toBeUndefined();311});312});313314describe('initializeWorkingDirectory', () => {315it('initializes folder for new session with chat session context', async () => {316const sessionService = new TestSessionService();317sessionService.isNewSessionId.mockReturnValue(true);318const { initializer, folderRepoManager } = createInitializer({ sessionService });319320const result = await initializer.initializeWorkingDirectory(321makeChatResource('untitled:new'), { stream: makeStream() },322{} as vscode.ChatParticipantToolToken, CancellationToken.None323);324325expect(result.cancelled).toBe(false);326expect(result.trusted).toBe(true);327expect(result.workspaceInfo.folder).toBeDefined();328expect(folderRepoManager.initializeFolderRepository).toHaveBeenCalled();329});330331it('gets existing folder for non-new session', async () => {332const sessionService = new TestSessionService();333sessionService.isNewSessionId.mockReturnValue(false);334const { initializer, folderRepoManager } = createInitializer({ sessionService });335336const result = await initializer.initializeWorkingDirectory(337makeChatResource('existing-session'), { stream: makeStream() },338{} as vscode.ChatParticipantToolToken, CancellationToken.None339);340341expect(result.cancelled).toBe(false);342expect(folderRepoManager.getFolderRepository).toHaveBeenCalled();343});344345it('initializes with active repository when no chat session context', async () => {346const { initializer, folderRepoManager } = createInitializer();347348const result = await initializer.initializeWorkingDirectory(349undefined, { stream: makeStream() },350{} as vscode.ChatParticipantToolToken, CancellationToken.None351);352353expect(result.cancelled).toBe(false);354expect(folderRepoManager.initializeFolderRepository).toHaveBeenCalledWith(355undefined, expect.anything(), expect.anything()356);357});358359it('returns cancelled when trust is denied', async () => {360const folderRepoManager = new TestFolderRepositoryManager();361folderRepoManager.initializeFolderRepository.mockResolvedValue({362folder: URI.file('/workspace') as unknown as vscode.Uri,363repository: undefined,364repositoryProperties: undefined,365worktree: undefined,366worktreeProperties: undefined,367trusted: false,368});369const { initializer } = createInitializer({ folderRepoManager });370371const result = await initializer.initializeWorkingDirectory(372undefined, { stream: makeStream() },373{} as vscode.ChatParticipantToolToken, CancellationToken.None374);375376expect(result.cancelled).toBe(true);377expect(result.trusted).toBe(false);378});379380it('returns cancelled when user cancels', async () => {381const folderRepoManager = new TestFolderRepositoryManager();382folderRepoManager.initializeFolderRepository.mockResolvedValue({383folder: undefined,384repository: undefined,385repositoryProperties: undefined,386worktree: undefined,387worktreeProperties: undefined,388trusted: true,389cancelled: true,390});391const { initializer } = createInitializer({ folderRepoManager });392393const result = await initializer.initializeWorkingDirectory(394undefined, { stream: makeStream() },395{} as vscode.ChatParticipantToolToken, CancellationToken.None396);397398expect(result.cancelled).toBe(true);399expect(result.trusted).toBe(true);400});401402it('parses session options from chat session context', async () => {403const sessionService = new TestSessionService();404sessionService.isNewSessionId.mockReturnValue(true);405const { initializer, folderRepoManager } = createInitializer({ sessionService });406407await initializer.initializeWorkingDirectory(408makeChatResource('untitled:new'),409{410folder: URI.file('/selected-repo') as unknown as vscode.Uri,411branch: 'feature-branch',412isolation: IsolationMode.Worktree,413stream: makeStream(),414},415{} as vscode.ChatParticipantToolToken, CancellationToken.None416);417418expect(folderRepoManager.initializeFolderRepository).toHaveBeenCalledWith(419expect.any(String),420expect.objectContaining({421branch: 'feature-branch',422isolation: IsolationMode.Worktree,423}),424expect.anything()425);426});427});428429describe('getOrCreateSession', () => {430it('creates new session and attaches stream', async () => {431const { initializer, sessionService } = createInitializer();432sessionService.isNewSessionId.mockReturnValue(true);433const disposables = new DisposableStore();434const stream = makeStream();435436const result = await initializer.getOrCreateSession(437makeRequest(), makeChatResource(), { stream },438disposables, CancellationToken.None439);440441expect(result.session).toBeDefined();442expect(result.isNewSession).toBe(true);443expect(result.model).toEqual(expect.objectContaining({ model: 'resolved-model' }));444expect(result.trusted).toBe(true);445expect(sessionService.createSession).toHaveBeenCalled();446expect(result.session!.object.attachStream).toHaveBeenCalledWith(stream);447expect(result.session!.object.setPermissionLevel).toHaveBeenCalled();448disposables.dispose();449});450451it('gets existing session for non-new session ID', async () => {452const { initializer, sessionService } = createInitializer();453sessionService.isNewSessionId.mockReturnValue(false);454const disposables = new DisposableStore();455456const result = await initializer.getOrCreateSession(457makeRequest(), makeChatResource('existing-session'), { stream: makeStream() },458disposables, CancellationToken.None459);460461expect(result.session).toBeDefined();462expect(result.isNewSession).toBe(false);463expect(sessionService.getSession).toHaveBeenCalled();464expect(sessionService.createSession).not.toHaveBeenCalled();465disposables.dispose();466});467468it('returns undefined session when working directory init is cancelled', async () => {469const folderRepoManager = new TestFolderRepositoryManager();470folderRepoManager.initializeFolderRepository.mockResolvedValue({471folder: undefined, repository: undefined, repositoryProperties: undefined,472worktree: undefined, worktreeProperties: undefined,473trusted: false,474});475const { initializer } = createInitializer({ folderRepoManager });476const disposables = new DisposableStore();477478const result = await initializer.getOrCreateSession(479makeRequest(), makeChatResource(), { stream: makeStream() },480disposables, CancellationToken.None481);482483expect(result.session).toBeUndefined();484expect(result.trusted).toBe(false);485disposables.dispose();486});487488it('returns undefined session when session service returns undefined', async () => {489const sessionService = new TestSessionService();490sessionService.isNewSessionId.mockReturnValue(false);491sessionService.getSession.mockResolvedValue(undefined);492const { initializer } = createInitializer({ sessionService });493const disposables = new DisposableStore();494const stream = makeStream();495496const result = await initializer.getOrCreateSession(497makeRequest(), makeChatResource('missing'), { stream },498disposables, CancellationToken.None499);500501expect(result.session).toBeUndefined();502expect(stream.warning).toHaveBeenCalled();503disposables.dispose();504});505506it('does not set worktree properties (moved to startRequest)', async () => {507const sessionService = new TestSessionService();508sessionService.isNewSessionId.mockReturnValue(true);509const folderRepoManager = new TestFolderRepositoryManager();510folderRepoManager.initializeFolderRepository.mockResolvedValue({511folder: URI.file('/workspace') as unknown as vscode.Uri,512repository: URI.file('/repo') as unknown as vscode.Uri,513repositoryProperties: undefined,514worktree: URI.file('/worktree') as unknown as vscode.Uri,515worktreeProperties: {516version: 2,517baseCommit: 'abc',518baseBranchName: 'main',519branchName: 'copilot/test',520repositoryPath: '/repo',521worktreePath: '/worktree',522},523trusted: true,524});525const { initializer, worktreeService } = createInitializer({ sessionService, folderRepoManager });526const disposables = new DisposableStore();527528await initializer.getOrCreateSession(529makeRequest(), makeChatResource(), { stream: makeStream() },530disposables, CancellationToken.None531);532533expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();534disposables.dispose();535});536537it('does not track workspace folder (moved to startRequest)', async () => {538const sessionService = new TestSessionService();539sessionService.isNewSessionId.mockReturnValue(true);540const { initializer, workspaceFolderService } = createInitializer({ sessionService });541const disposables = new DisposableStore();542543await initializer.getOrCreateSession(544makeRequest(), makeChatResource(), { stream: makeStream() },545disposables, CancellationToken.None546);547548expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();549disposables.dispose();550});551552it('does not record request metadata (moved to startRequest)', async () => {553const { initializer, metadataStore } = createInitializer();554const disposables = new DisposableStore();555556await initializer.getOrCreateSession(557makeRequest(), makeChatResource(), { stream: makeStream() },558disposables, CancellationToken.None559);560561expect(metadataStore.updateRequestDetails).not.toHaveBeenCalled();562disposables.dispose();563});564});565566describe('createDelegatedSession', () => {567it('creates session and resolves model', async () => {568const { initializer, sessionService } = createInitializer();569const workspace: IWorkspaceInfo = {570folder: URI.file('/workspace') as unknown as vscode.Uri,571repository: undefined,572repositoryProperties: undefined,573worktree: undefined,574worktreeProperties: undefined,575};576577const session = await initializer.createDelegatedSession(578makeRequest(), workspace, { mcpServerMappings: new Map() },579CancellationToken.None580);581582expect(session).toBeDefined();583expect(sessionService.createSession).toHaveBeenCalled();584});585586it('does not set worktree properties or track workspace folder (moved to startRequest)', async () => {587const { initializer, worktreeService, workspaceFolderService, metadataStore } = createInitializer();588const workspace: IWorkspaceInfo = {589folder: URI.file('/workspace') as unknown as vscode.Uri,590repository: URI.file('/repo') as unknown as vscode.Uri,591repositoryProperties: undefined,592worktree: URI.file('/worktree') as unknown as vscode.Uri,593worktreeProperties: {594version: 2,595baseCommit: 'abc',596baseBranchName: 'main',597branchName: 'copilot/test',598repositoryPath: '/repo',599worktreePath: '/worktree',600},601};602603await initializer.createDelegatedSession(604makeRequest(), workspace, { mcpServerMappings: new Map() },605CancellationToken.None606);607608expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();609expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();610expect(metadataStore.updateRequestDetails).not.toHaveBeenCalled();611});612});613});614615616