Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts
13405 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 { beforeEach, describe, expect, it, vi } from 'vitest';6import type * as vscode from 'vscode';7import { ILogService } from '../../../../platform/log/common/logService';8import { mock } from '../../../../util/common/test/simpleMock';9import { Event } from '../../../../util/vs/base/common/event';10import { URI } from '../../../../util/vs/base/common/uri';11import { ChatSessionStatus } from '../../../../vscodeTypes';12import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';13import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';14import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService';15import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';16import { IWorkspaceInfo } from '../../common/workspaceInfo';17import { IPullRequestDetectionService } from '../pullRequestDetectionService';18import { SessionCompletionInfo, SessionRequestLifecycle } from '../sessionRequestLifecycle';1920// ─── Test Helpers ────────────────────────────────────────────────2122class TestWorktreeService extends mock<IChatSessionWorktreeService>() {23declare readonly _serviceBrand: undefined;24override handleRequestCompleted = vi.fn(async () => { });25override setWorktreeProperties = vi.fn(async () => { });26}2728class TestCheckpointService extends mock<IChatSessionWorktreeCheckpointService>() {29declare readonly _serviceBrand: undefined;30override handleRequest = vi.fn(async () => { });31override handleRequestCompleted = vi.fn(async () => { });32}3334class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {35declare readonly _serviceBrand: undefined;36override handleRequestCompleted = vi.fn(async () => { });37override trackSessionWorkspaceFolder = vi.fn(async () => { });38}3940class TestPrDetectionService extends mock<IPullRequestDetectionService>() {41declare readonly _serviceBrand: undefined;42override onDidDetectPullRequest = Event.None;43override handlePullRequestCreated = vi.fn();44}4546class TestMetadataStore extends mock<IChatSessionMetadataStore>() {47declare readonly _serviceBrand: undefined;48override updateRequestDetails = vi.fn(async () => { });49}5051class TestLogService extends mock<ILogService>() {52declare readonly _serviceBrand: undefined;53override error = vi.fn();54}5556function makeRequest(id: string = 'req-1'): vscode.ChatRequest {57return { id } as unknown as vscode.ChatRequest;58}5960function makeSession(overrides?: Partial<SessionCompletionInfo>): SessionCompletionInfo {61return {62status: ChatSessionStatus.Completed,63workspace: {64folder: URI.file('/workspace') as unknown as vscode.Uri,65repository: undefined,66repositoryProperties: undefined,67worktree: undefined,68worktreeProperties: undefined,69},70createdPullRequestUrl: undefined,71...overrides,72};73}7475function makeIsolatedSession(overrides?: Partial<SessionCompletionInfo>): SessionCompletionInfo {76return makeSession({77workspace: {78folder: URI.file('/workspace') as unknown as vscode.Uri,79repository: URI.file('/repo') as unknown as vscode.Uri,80repositoryProperties: undefined,81worktree: URI.file('/worktree') as unknown as vscode.Uri,82worktreeProperties: {83version: 2,84baseCommit: 'abc',85baseBranchName: 'main',86branchName: 'copilot/test',87repositoryPath: '/repo',88worktreePath: '/worktree',89},90},91...overrides,92});93}9495function makeToken(cancelled: boolean = false): vscode.CancellationToken {96return { isCancellationRequested: cancelled, onCancellationRequested: vi.fn() } as unknown as vscode.CancellationToken;97}9899function makeWorkspace(overrides?: Partial<IWorkspaceInfo>): IWorkspaceInfo {100return {101folder: URI.file('/workspace') as unknown as vscode.Uri,102repository: undefined,103repositoryProperties: undefined,104worktree: undefined,105worktreeProperties: undefined,106...overrides,107};108}109110function makeIsolatedWorkspace(): IWorkspaceInfo {111return makeWorkspace({112repository: URI.file('/repo') as unknown as vscode.Uri,113worktree: URI.file('/worktree') as unknown as vscode.Uri,114worktreeProperties: {115version: 2,116baseCommit: 'abc',117baseBranchName: 'main',118branchName: 'copilot/test',119repositoryPath: '/repo',120worktreePath: '/worktree',121},122});123}124125// ─── Tests ───────────────────────────────────────────────────────126127describe('SessionRequestLifecycle', () => {128let worktreeService: TestWorktreeService;129let checkpointService: TestCheckpointService;130let workspaceFolderService: TestWorkspaceFolderService;131let prDetectionService: TestPrDetectionService;132let metadataStore: TestMetadataStore;133let logService: TestLogService;134let handler: SessionRequestLifecycle;135136beforeEach(() => {137vi.restoreAllMocks();138worktreeService = new TestWorktreeService();139checkpointService = new TestCheckpointService();140workspaceFolderService = new TestWorkspaceFolderService();141prDetectionService = new TestPrDetectionService();142metadataStore = new TestMetadataStore();143logService = new TestLogService();144handler = new SessionRequestLifecycle(145worktreeService,146checkpointService,147workspaceFolderService,148prDetectionService,149metadataStore,150logService,151);152});153154describe('startRequest', () => {155it('creates baseline checkpoint on first request', async () => {156const request = makeRequest();157await handler.startRequest('session-1', request, true, makeWorkspace());158expect(checkpointService.handleRequest).toHaveBeenCalledWith('session-1');159});160161it('skips baseline checkpoint on subsequent requests', async () => {162const request = makeRequest();163await handler.startRequest('session-1', request, false, makeWorkspace());164expect(checkpointService.handleRequest).not.toHaveBeenCalled();165});166167it('records request metadata with modeInstructions', async () => {168const request = makeRequest();169(request as any).modeInstructions2 = {170name: 'test',171content: 'instructions',172};173await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent');174175expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(176'session-1',177[{178vscodeRequestId: 'req-1',179agentId: 'test-agent',180modeInstructions: expect.objectContaining({ name: 'test', content: 'instructions' }),181}]182);183});184185it('records metadata without modeInstructions when request has no modeInstructions2', async () => {186const request = makeRequest();187await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent');188189expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(190'session-1',191[{192vscodeRequestId: 'req-1',193agentId: 'test-agent',194modeInstructions: undefined,195}]196);197});198199it('sets worktree properties on first request with worktree', async () => {200const workspace = makeIsolatedWorkspace();201await handler.startRequest('session-1', makeRequest(), true, workspace);202203expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(204'session-1',205expect.objectContaining({ branchName: 'copilot/test' })206);207});208209it('does not set worktree properties on subsequent requests', async () => {210const workspace = makeIsolatedWorkspace();211await handler.startRequest('session-1', makeRequest(), false, workspace);212213expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();214});215216it('tracks workspace folder for non-isolated session on first request', async () => {217const workspace = makeWorkspace();218await handler.startRequest('session-1', makeRequest(), true, workspace);219220expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();221});222223it('does not track workspace folder for isolated session', async () => {224const workspace = makeIsolatedWorkspace();225await handler.startRequest('session-1', makeRequest(), true, workspace);226227expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();228});229});230231describe('endRequest', () => {232it('commits worktree changes for isolated session', async () => {233const request = makeRequest();234const session = makeIsolatedSession();235236await handler.startRequest('session-1', request, false, makeWorkspace());237await handler.endRequest('session-1', request, session, makeToken());238239expect(worktreeService.handleRequestCompleted).toHaveBeenCalledWith('session-1');240expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();241expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-1');242});243244it('stages workspace changes for non-isolated session with working directory', async () => {245const request = makeRequest();246const session = makeSession(); // non-isolated, has folder247248await handler.startRequest('session-1', request, false, makeWorkspace());249await handler.endRequest('session-1', request, session, makeToken());250251expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');252expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();253expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-1');254});255256it('skips commit/stage when session status is not Completed', async () => {257const request = makeRequest();258const session = makeSession({ status: ChatSessionStatus.InProgress });259260await handler.startRequest('session-1', request, false, makeWorkspace());261await handler.endRequest('session-1', request, session, makeToken());262263expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();264expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();265expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();266});267268it('skips commit/stage when session status is undefined', async () => {269const request = makeRequest();270const session = makeSession({ status: undefined });271272await handler.startRequest('session-1', request, false, makeWorkspace());273await handler.endRequest('session-1', request, session, makeToken());274275expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();276expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();277});278279it('skips workspace commit when no working directory', async () => {280const request = makeRequest();281const session = makeSession({282workspace: {283folder: undefined,284repository: undefined,285repositoryProperties: undefined,286worktree: undefined,287worktreeProperties: undefined,288},289});290291await handler.startRequest('session-1', request, false, makeWorkspace());292await handler.endRequest('session-1', request, session, makeToken());293294expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();295expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();296// Checkpoint should still be created297expect(checkpointService.handleRequestCompleted).toHaveBeenCalled();298});299300it('defers handling when multiple requests are in flight (steering)', async () => {301const req1 = makeRequest('req-1');302const req2 = makeRequest('req-2');303const session = makeSession();304305await handler.startRequest('session-1', req1, false, makeWorkspace());306await handler.startRequest('session-1', req2, false, makeWorkspace());307308// First request completes — should defer (2 pending)309await handler.endRequest('session-1', req1, session, makeToken());310expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();311expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();312expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();313314// Second (last) request completes — should proceed315await handler.endRequest('session-1', req2, session, makeToken());316expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');317expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-2');318});319320it('skips everything when token is cancelled', async () => {321const request = makeRequest();322const session = makeSession();323324await handler.startRequest('session-1', request, false, makeWorkspace());325await handler.endRequest('session-1', request, session, makeToken(true));326327expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();328expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();329expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();330});331332it('calls PR detection service on completion', async () => {333const request = makeRequest();334const session = makeSession();335336await handler.startRequest('session-1', request, false, makeWorkspace());337await handler.endRequest('session-1', request, session, makeToken());338339// PR detection is fire-and-forget; wait for microtask340await new Promise(resolve => setTimeout(resolve, 10));341expect(prDetectionService.handlePullRequestCreated).toHaveBeenCalledWith('session-1', undefined);342});343344it('cleans up tracked request even when commit throws', async () => {345workspaceFolderService.handleRequestCompleted.mockRejectedValue(new Error('commit failed'));346const request = makeRequest();347const session = makeSession();348349await handler.startRequest('session-1', request, false, makeWorkspace());350await expect(handler.endRequest('session-1', request, session, makeToken())).rejects.toThrow('commit failed');351352// After the error, a new request for the same session should proceed normally353workspaceFolderService.handleRequestCompleted.mockResolvedValue();354const req2 = makeRequest('req-2');355await handler.startRequest('session-1', req2, false, makeWorkspace());356await handler.endRequest('session-1', req2, session, makeToken());357expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledTimes(2);358});359360it('handles request without prior tracking gracefully', async () => {361const request = makeRequest();362const session = makeSession();363364// Not tracked, but should still work (pendingRequests is undefined → size check skipped)365await handler.endRequest('session-1', request, session, makeToken());366expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalled();367});368});369});370371372