Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.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 { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';6import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';7import * as vscode from 'vscode';8import { Uri } from 'vscode';9import { NullChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';10import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';11import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';12import { NullNativeEnvService } from '../../../../platform/env/common/nullEnvService';13import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';14import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';15import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';16import { IOctoKitService } from '../../../../platform/github/common/githubService';17import { ILogService } from '../../../../platform/log/common/logService';18import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index';19import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';20import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';21import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';22import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';23import { IWorkspaceService, NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';24import { mock } from '../../../../util/common/test/simpleMock';25import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';26import { Event } from '../../../../util/vs/base/common/event';27import { Disposable, DisposableStore } from '../../../../util/vs/base/common/lifecycle';28import { sep } from '../../../../util/vs/base/common/path';29import { URI } from '../../../../util/vs/base/common/uri';30import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';31import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';32import { NullPromptVariablesService } from '../../../prompt/node/promptVariablesService';33import { ChatSummarizerProvider } from '../../../prompt/node/summarizer';34import { createExtensionUnitTestingServices } from '../../../test/node/services';35import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';36import { type IToolsService } from '../../../tools/common/toolsService';37import { mockLanguageModelChat } from '../../../tools/node/test/searchToolTestUtils';38import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';39import { RepositoryProperties } from '../../common/chatSessionMetadataStore';40import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';41import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService';42import { IChatSessionWorktreeService, type ChatSessionWorktreeFile, type ChatSessionWorktreeProperties, type ChatSessionWorktreePropertiesV2 } from '../../common/chatSessionWorktreeService';43import { IChatFolderMruService } from '../../common/folderRepositoryManager';44import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore';45import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo';46import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService';47import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli';48import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver';49import { CopilotCLISession, CopilotCLISessionInput, ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';50import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';51import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler';52import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers';53import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../copilotcli/node/userInputHelpers';54import { CustomSessionTitleService } from '../../copilotcli/vscode-node/customSessionTitleServiceImpl';55import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant } from '../copilotCLIChatSessionsContribution';56import { CopilotCloudSessionsProvider } from '../copilotCloudSessionsProvider';57import { CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';58import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';5960// Mock terminal integration to avoid importing PowerShell asset (.ps1) which Vite cannot parse during tests61vi.mock('../copilotCLITerminalIntegration', () => {62// Minimal stand-in for createServiceIdentifier63const createServiceIdentifier = (name: string) => {64const fn: any = () => { /* decorator no-op */ };65fn.toString = () => name;66return fn;67};68class CopilotCLITerminalIntegration {69dispose() { }70openTerminal = vi.fn(async () => { });71}72return {73ICopilotCLITerminalIntegration: createServiceIdentifier('ICopilotCLITerminalIntegration'),74CopilotCLITerminalIntegration75};76});7778// Mock vscode.commands.executeCommand so we can control delegation behavior in tests.79// By default it throws (simulating commands API not being available), which causes80// createCLISessionAndSubmitRequest to fall into its catch block and call handleRequest directly.81// The workaround tests override this to simulate the full VS Code core round-trip.82const { mockExecuteCommand } = vi.hoisted(() => ({83mockExecuteCommand: vi.fn()84}));8586vi.mock('vscode', async (importOriginal) => {87const actual = await import('../../../../vscodeTypes');88return {89...actual,90env: {91appName: 'VS Code'92},93version: 'test-vscode-version',94extensions: {95getExtension: vi.fn(() => ({ packageJSON: { version: 'test-version' } }))96},97commands: {98executeCommand: mockExecuteCommand99},100workspace: {101isAgentSessionsWorkspace: false102}103};104});105106class FakeToolsService extends mock<IToolsService>() {107nextConfirmationButton: string | undefined = undefined;108override getTool(name: string) {109if (name === 'vscode_get_modified_files_confirmation') {110return { name } as any;111}112return undefined;113}114override invokeTool = vi.fn(async (name: string, _options: unknown, _token: unknown) => {115if (name === 'vscode_get_modified_files_confirmation') {116const button = this.nextConfirmationButton;117if (button !== undefined) {118return new LanguageModelToolResult2([new LanguageModelTextPart(button)]);119}120return new LanguageModelToolResult2([]);121}122return new LanguageModelToolResult2([]);123});124}125126class FakeChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {127private _sessionWorkspaceFolders = new Map<string, vscode.Uri>();128private _sessionWorkspaceFolderRepositories = new Map<string, vscode.Uri | undefined>();129private _workspaceChanges = new Map<string, readonly ChatSessionWorktreeFile[] | undefined>();130override trackSessionWorkspaceFolder = vi.fn(async (sessionId: string, workspaceFolderUri: string, repositoryProperties?: RepositoryProperties) => {131this._sessionWorkspaceFolders.set(sessionId, vscode.Uri.file(workspaceFolderUri));132this._sessionWorkspaceFolderRepositories.set(sessionId, repositoryProperties?.repositoryPath ? vscode.Uri.file(repositoryProperties.repositoryPath) : undefined);133});134override deleteTrackedWorkspaceFolder = vi.fn(async (sessionId: string) => {135this._sessionWorkspaceFolders.delete(sessionId);136this._sessionWorkspaceFolderRepositories.delete(sessionId);137});138override getSessionWorkspaceFolder = vi.fn(async (sessionId: string): Promise<vscode.Uri | undefined> => {139return this._sessionWorkspaceFolders.get(sessionId);140});141override getSessionWorkspaceFolderEntry = vi.fn(async (sessionId: string) => {142const folder = this._sessionWorkspaceFolders.get(sessionId);143if (!folder) {144return undefined;145}146147return {148folderPath: folder.fsPath,149timestamp: Date.now()150};151});152override getRepositoryProperties = vi.fn(async (_sessionId: string): Promise<RepositoryProperties | undefined> => {153return undefined;154});155override handleRequestCompleted = vi.fn(async (_sessionId: string): Promise<void> => { });156override getWorkspaceChanges = vi.fn(async (sessionId: string): Promise<readonly ChatSessionWorktreeFile[] | undefined> => {157return this._workspaceChanges.get(sessionId);158});159override clearWorkspaceChanges(sessionIdOrFolderUri: string | vscode.Uri): string[] {160if (typeof sessionIdOrFolderUri === 'string') {161this._workspaceChanges.delete(sessionIdOrFolderUri);162}163return [];164}165}166167class FakeChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {168constructor() {169super();170}171override createWorktree = vi.fn(async () => undefined) as unknown as IChatSessionWorktreeService['createWorktree'];172override getWorktreeProperties: any = vi.fn(async (_id: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => undefined);173override setWorktreeProperties = vi.fn(async () => { });174override getWorktreePath: any = vi.fn(async (_id: string): Promise<vscode.Uri | undefined> => undefined);175override handleRequestCompleted = vi.fn(async () => { });176override getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined> {177return Promise.resolve(undefined);178}179}180181class FakeChatSessionWorktreeCheckpointService extends mock<IChatSessionWorktreeCheckpointService>() {182constructor() {183super();184}185override handleRequest = vi.fn(async () => { });186override handleRequestCompleted = vi.fn(async () => { });187}188189190class FakeModels {191_serviceBrand: undefined;192resolveModel = vi.fn(async (modelId: string) => modelId);193getDefaultModel = vi.fn(async () => 'base');194getModels = vi.fn(async () => [{ id: 'base', name: 'Base', maxContextWindowTokens: 128000, supportsVision: false }] as CopilotCLIModelInfo[]);195setDefaultModel = vi.fn(async () => { });196registerLanguageModelChatProvider = vi.fn();197toModelProvider = vi.fn((id: string) => id); // passthrough198}199200class FakeGitService extends mock<IGitService>() {201override activeRepository = { get: () => undefined } as unknown as IGitService['activeRepository'];202override onDidFinishInitialization = Event.None;203override onDidOpenRepository = Event.None;204override repositories: RepoContext[] = [];205private _recentRepositories: { rootUri: vscode.Uri; lastAccessTime: number }[] = [];206setRepo(repos: RepoContext) {207this.repositories = [repos];208}209override async getRepository(uri: URI, forceOpen?: boolean): Promise<RepoContext | undefined> {210if (this.repositories.length === 1) {211return Promise.resolve(this.repositories[0]);212}213return undefined;214}215override getRecentRepositories = vi.fn((): { rootUri: vscode.Uri; lastAccessTime: number }[] => {216return this._recentRepositories;217});218setTestRecentRepositories(repos: { rootUri: vscode.Uri; lastAccessTime: number }[]): void {219this._recentRepositories = repos;220}221}222223// Cloud provider fake for delegate scenario224class FakeCloudProvider extends mock<CopilotCloudSessionsProvider>() {225override delegate = vi.fn(async () => ({226uri: vscode.Uri.parse('pr://1'),227title: 'PR Title',228description: 'PR Description',229author: 'Test Author',230linkTag: '#1'231})) as unknown as CopilotCloudSessionsProvider['delegate'];232}233234235function createChatContext(sessionId: string, isUntitled: boolean, ...requests: TestChatRequest[]): vscode.ChatContext {236const resource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}` });237for (const request of requests) {238request.sessionResource = resource;239}240return {241history: [],242yieldRequested: false,243chatSessionContext: {244chatSessionItem: { resource, label: 'temp' } as vscode.ChatSessionItem,245isUntitled246} as vscode.ChatSessionContext,247} as vscode.ChatContext;248}249250class TestCopilotCLISession extends CopilotCLISession {251public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; model: { model: string; reasoningEffort?: string } | undefined; authInfo: NonNullable<SessionOptions['authInfo']>; token: vscode.CancellationToken }> = [];252public permissionLevel: string | undefined;253public static nextHandleRequestResult: Promise<void> | undefined;254public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput) => Promise<void>) | undefined;255public static statusOverride?: vscode.ChatSessionStatus;256override get status(): vscode.ChatSessionStatus | undefined {257return TestCopilotCLISession.statusOverride;258}259override handleRequest(request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, authInfo: NonNullable<SessionOptions['authInfo']>, token: vscode.CancellationToken): Promise<void> {260this.requests.push({ input, attachments, model, authInfo, token });261if (TestCopilotCLISession.handleRequestHook) {262return TestCopilotCLISession.handleRequestHook(request, input);263}264return TestCopilotCLISession.nextHandleRequestResult ?? Promise.resolve();265}266override setPermissionLevel(level: string | undefined): void {267this.permissionLevel = level;268super.setPermissionLevel(level);269}270}271272273class FakeCopilotCLISessionService extends mock<ICopilotCLISessionService>() {274private _sessionWorkingDirs = new Map<string, vscode.Uri>();275override tryGetPartialSessionHistory: ICopilotCLISessionService['tryGetPartialSessionHistory'] = vi.fn(async () => undefined);276277override getSessionWorkingDirectory = vi.fn((sessionId: string): vscode.Uri | undefined => {278return this._sessionWorkingDirs.get(sessionId);279});280281setTestSessionWorkingDirectory(sessionId: string, uri: vscode.Uri): void {282this._sessionWorkingDirs.set(sessionId, uri);283}284}285286describe('CopilotCLIChatSessionParticipant.handleRequest', () => {287const disposables = new DisposableStore();288let promptResolver: CopilotCLIPromptResolver;289let itemProvider: CopilotCLIChatSessionItemProvider;290let cloudProvider: FakeCloudProvider;291let summarizer: ChatSummarizerProvider;292let worktree: FakeChatSessionWorktreeService;293let worktreeCheckpointService: FakeChatSessionWorktreeCheckpointService;294let workspaceFolderService: FakeChatSessionWorkspaceFolderService;295let git: FakeGitService;296let models: FakeModels;297let sessionService: CopilotCLISessionService;298let telemetry: ITelemetryService;299let tools: FakeToolsService;300let participant: CopilotCLIChatSessionParticipant;301let workspaceService: IWorkspaceService;302let instantiationService: IInstantiationService;303let logService: ILogService;304let configurationService: InMemoryConfigurationService;305let manager: MockCliSdkSessionManager;306let mcpHandler: ICopilotCLIMCPHandler;307let folderRepositoryManager: CopilotCLIFolderRepositoryManager;308let cliSessionServiceForFolderManager: FakeCopilotCLISessionService;309let contentProvider: CopilotCLIChatSessionContentProvider;310let sdk: ICopilotCLISDK;311let customSessionTitleService: CustomSessionTitleService;312const cliSessions: TestCopilotCLISession[] = [];313314beforeEach(async () => {315cliSessions.length = 0;316TestCopilotCLISession.nextHandleRequestResult = undefined;317TestCopilotCLISession.handleRequestHook = undefined;318TestCopilotCLISession.statusOverride = undefined;319// By default, simulate VS Code core opening the delegated session and320// re-invoking handleRequest with the copilotcli:// resource. This matches321// the production flow where executeCommand opens the session.322// The chatSessionContext lost workaround tests override this.323mockExecuteCommand.mockImplementation(async (command: string, args: any) => {324if (command === 'workbench.action.chat.openSessionWithPrompt.copilotcli') {325const callbackRequest = new TestChatRequest(args.prompt);326callbackRequest.sessionResource = args.resource;327const callbackContext = createChatContext(args.resource.path.slice(1), false, callbackRequest);328const callbackStream = new MockChatResponseStream();329const callbackToken = disposables.add(new CancellationTokenSource()).token;330await participant.createHandler()(callbackRequest, callbackContext, callbackStream, callbackToken);331}332});333sdk = {334getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),335getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'valid-token', host: 'https://github.com' })),336} as unknown as ICopilotCLISDK;337const services = disposables.add(createExtensionUnitTestingServices());338const accessor = services.createTestingAccessor();339disposables.add(accessor);340promptResolver = new class extends mock<CopilotCLIPromptResolver>() {341override resolvePrompt = vi.fn(async (request: vscode.ChatRequest, prompt: string | undefined, _additionalReferences: vscode.ChatPromptReference[], _workspaceInfo: IWorkspaceInfo, _additionalWorkspaces: IWorkspaceInfo[], _token: vscode.CancellationToken) => {342return { prompt: prompt ?? request.prompt, attachments: [], references: [] };343});344}();345itemProvider = new class extends mock<CopilotCLIChatSessionItemProvider>() {346override swap = vi.fn();347override notifySessionsChange = vi.fn();348override untitledSessionIdMapping = new Map<string, string>();349override sdkToUntitledUriMapping = new Map<string, Uri>();350override isNewSession = vi.fn((_session: string) => false);351override detectPullRequestOnSessionOpen = vi.fn(async () => { });352}();353cloudProvider = new FakeCloudProvider();354summarizer = new class extends mock<ChatSummarizerProvider>() {355override provideChatSummary(_context: vscode.ChatContext) { return Promise.resolve('summary text'); }356}();357worktree = new FakeChatSessionWorktreeService();358worktreeCheckpointService = new FakeChatSessionWorktreeCheckpointService();359workspaceFolderService = new FakeChatSessionWorkspaceFolderService();360git = new FakeGitService();361models = new FakeModels();362cliSessionServiceForFolderManager = new FakeCopilotCLISessionService();363telemetry = new NullTelemetryService();364tools = new FakeToolsService();365workspaceService = new NullWorkspaceService([URI.file('/workspace')]);366const logger = accessor.get(ILogService);367logService = accessor.get(ILogService);368mcpHandler = new class extends mock<ICopilotCLIMCPHandler>() {369override loadMcpConfig = vi.fn(async () => {370return { mcpConfig: undefined, disposable: Disposable.None };371});372}();373const delegationService = new class extends mock<IChatDelegationSummaryService>() {374override async summarize(context: vscode.ChatContext, token: vscode.CancellationToken): Promise<string | undefined> {375return undefined;376}377}();378const fileSystem = new MockFileSystemService();379class FakeUserQuestionHandler implements IUserQuestionHandler {380_serviceBrand: undefined;381async askUserQuestion(question: IQuestion, toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken): Promise<IQuestionAnswer | undefined> {382return undefined;383}384}385386instantiationService = {387invokeFunction<R, TS extends any[] = []>(fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R {388return fn(accessor, ...args);389},390createInstance: (ctor: unknown, workspaceInfo: any, agentName: any, sdkSession: any) => {391if (ctor === CopilotCLISessionWorkspaceTracker) {392return new class extends mock<CopilotCLISessionWorkspaceTracker>() {393override async initialize(): Promise<void> { return; }394override shouldShowSession(_sessionId: string): { isOldGlobalSession?: boolean; isWorkspaceSession?: boolean } {395return { isOldGlobalSession: false, isWorkspaceSession: true };396}397}();398}399const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any);400cliSessions.push(session);401return disposables.add(session);402}403} as unknown as IInstantiationService;404customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext, accessor.get(IInstantiationService), logService, new MockChatSessionMetadataStore());405sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), models as unknown as ICopilotCLIModels));406407manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager;408contentProvider = new class extends mock<CopilotCLIChatSessionContentProvider>() {409override notifySessionOptionsChange = vi.fn((_resource: vscode.Uri, _updates: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }>): void => {410// tracked by vi.fn411});412override trackActiveSession = vi.fn();413override untrackActiveSession = vi.fn();414}();415folderRepositoryManager = new CopilotCLIFolderRepositoryManager(416worktree,417workspaceFolderService,418cliSessionServiceForFolderManager as unknown as ICopilotCLISessionService,419git,420workspaceService,421logService,422tools,423fileSystem,424new MockChatSessionMetadataStore()425);426427instantiationService = accessor.get(IInstantiationService);428configurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService;429await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);430431participant = new CopilotCLIChatSessionParticipant(432contentProvider,433promptResolver,434itemProvider,435cloudProvider,436undefined,437git,438models as unknown as ICopilotCLIModels,439new NullCopilotCLIAgents(),440sessionService,441worktree,442worktreeCheckpointService,443workspaceFolderService,444telemetry,445logger,446disposables.add(new MockPromptsService()),447delegationService,448folderRepositoryManager,449configurationService,450sdk,451new MockChatSessionMetadataStore(),452customSessionTitleService,453new (mock<IOctoKitService>())(),454);455});456457afterEach(() => {458vi.restoreAllMocks();459disposables.clear();460});461462it('creates new session for untitled context and invokes request', async () => {463const request = new TestChatRequest('Say hi');464const context = createChatContext('temp-new', true, request);465const stream = new MockChatResponseStream();466const token = disposables.add(new CancellationTokenSource()).token;467const authInfo = await sdk.getAuthInfo();468expect(cliSessions.length).toBe(0);469470await participant.createHandler()(request, context, stream, token);471472expect(cliSessions.length).toBe(1);473expect(cliSessions[0].requests.length).toBe(1);474expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], model: { model: 'base' }, authInfo, token });475});476477it('uses permissionLevel from initial session options', async () => {478const request = new TestChatRequest('Say hi');479const context = createChatContext('temp-new', true, request);480(context.chatSessionContext as { initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }> }).initialSessionOptions = [{ optionId: 'permissionLevel', value: 'autopilot' }];481const stream = new MockChatResponseStream();482const token = disposables.add(new CancellationTokenSource()).token;483484await participant.createHandler()(request, context, stream, token);485486expect(cliSessions.length).toBe(1);487expect(cliSessions[0].permissionLevel).toBe('autopilot');488});489490it('applies live permissionLevel option changes to an active session', async () => {491const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;492(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;493(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();494const activeSession = {495sessionId: 'sdk-session',496setPermissionLevel: vi.fn(),497} as unknown as ICopilotCLISession;498itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);499provider.trackActiveSession('untitled-session', activeSession);500501await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [502{ optionId: 'permissionLevel', value: 'autopilot' }503], disposables.add(new CancellationTokenSource()).token);504505expect(activeSession.setPermissionLevel).toHaveBeenCalledWith('autopilot');506});507508it('scopes live permissionLevel changes to the targeted session', async () => {509const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;510(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;511(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();512const sessionA = { sessionId: 'sdk-a', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;513const sessionB = { sessionId: 'sdk-b', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;514itemProvider.untitledSessionIdMapping.set('resource-a', sessionA.sessionId);515itemProvider.untitledSessionIdMapping.set('resource-b', sessionB.sessionId);516provider.trackActiveSession('resource-a', sessionA);517provider.trackActiveSession('resource-b', sessionB);518519await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/resource-b'), [520{ optionId: 'permissionLevel', value: 'autopilot' }521], disposables.add(new CancellationTokenSource()).token);522523expect(sessionB.setPermissionLevel).toHaveBeenCalledWith('autopilot');524expect(sessionA.setPermissionLevel).not.toHaveBeenCalled();525});526527it('clears permissionLevel on an active session when option value is undefined', async () => {528const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;529(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;530(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();531const activeSession = { sessionId: 'sdk-session', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;532itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);533provider.trackActiveSession('untitled-session', activeSession);534535await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [536{ optionId: 'permissionLevel', value: undefined }537], disposables.add(new CancellationTokenSource()).token);538539expect(activeSession.setPermissionLevel).toHaveBeenCalledWith(undefined);540});541542it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => {543const worktreeProperties = {544autoCommit: true,545baseCommit: 'deadbeef',546branchName: 'test',547repositoryPath: `${sep}repo`,548worktreePath: `${sep}worktree`,549version: 1550} satisfies ChatSessionWorktreeProperties;551// Set up untitled session folder552folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));553// Configure git to return repository for the folder554git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], kind: 'repository' } as unknown as RepoContext);555// Configure worktree service to return worktree properties when createWorktree is called556(worktree.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(worktreeProperties);557558const request = new TestChatRequest('Say hi');559const context = createChatContext('untitled:temp-new', true, request);560const stream = new MockChatResponseStream();561const token = disposables.add(new CancellationTokenSource()).token;562563await participant.createHandler()(request, context, stream, token);564565expect(cliSessions.length).toBe(1);566expect(cliSessions[0].workspace.worktreeProperties).toBeDefined();567expect(getWorkingDirectory(cliSessions[0].workspace)?.fsPath).toBe(`${sep}worktree`);568expect(mcpHandler.loadMcpConfig).toHaveBeenCalled();569// Prompt resolver should receive the effective workingDirectory.570expect(promptResolver.resolvePrompt).toHaveBeenCalled();571expect(getWorkingDirectory((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][3])?.fsPath).toBe(`${sep}worktree`);572});573574it('falls back to workspace workingDirectory when isolation is enabled but worktree creation fails', async () => {575// Set up untitled session folder (no git repo)576folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}workspace`));577// Git returns no repository for this folder (default FakeGitService behavior)578const request = new TestChatRequest('Say hi');579const context = createChatContext('untitled:temp-new', true, request);580const stream = new MockChatResponseStream();581const token = disposables.add(new CancellationTokenSource()).token;582583await participant.createHandler()(request, context, stream, token);584585expect(cliSessions.length).toBe(1);586expect(cliSessions[0].workspace.worktreeProperties).toBeUndefined();587expect(getWorkingDirectory(cliSessions[0].workspace)?.fsPath).toBe(`${sep}workspace`);588expect(mcpHandler.loadMcpConfig).toHaveBeenCalled();589// Prompt resolver should receive the effective workingDirectory.590expect(promptResolver.resolvePrompt).toHaveBeenCalled();591expect(getWorkingDirectory((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][3])?.fsPath).toBe(`${sep}workspace`);592});593594it('reuses existing session (non-untitled) and does not create new one', async () => {595const sessionId = 'existing-123';596const sdkSession = new MockCliSdkSession(sessionId, new Date());597manager.sessions.set(sessionId, sdkSession);598const authInfo = await sdk.getAuthInfo();599const request = new TestChatRequest('Continue');600const context = createChatContext(sessionId, false, request);601const stream = new MockChatResponseStream();602const token = disposables.add(new CancellationTokenSource()).token;603604expect(cliSessions.length).toBe(0);605606await participant.createHandler()(request, context, stream, token);607608expect(cliSessions.length).toBe(1);609expect(cliSessions[0].sessionId).toBe(sessionId);610expect(cliSessions[0].requests.length).toBe(1);611expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Continue' }, attachments: [], model: { model: 'base' }, authInfo, token });612613expect(itemProvider.swap).not.toHaveBeenCalled();614});615616it('maps known slash commands to CLI command input for existing sessions', async () => {617const sessionId = 'existing-compact';618const sdkSession = new MockCliSdkSession(sessionId, new Date());619manager.sessions.set(sessionId, sdkSession);620const request = new TestChatRequest('');621request.command = 'compact';622const context = createChatContext(sessionId, false, request);623const stream = new MockChatResponseStream();624const token = disposables.add(new CancellationTokenSource()).token;625626await participant.createHandler()(request, context, stream, token);627628expect(cliSessions.length).toBe(1);629expect(cliSessions[0].requests).toHaveLength(1);630expect(cliSessions[0].requests[0].input).toEqual({ command: 'compact', prompt: '' });631expect(promptResolver.resolvePrompt).not.toHaveBeenCalled();632});633634it.skip('returns early when yield is requested while the session is still running', async () => {635const sessionId = 'existing-yield';636const sdkSession = new MockCliSdkSession(sessionId, new Date());637manager.sessions.set(sessionId, sdkSession);638let resolveHandleRequest!: () => void;639let yieldRequested = false;640TestCopilotCLISession.nextHandleRequestResult = new Promise<void>(resolve => {641resolveHandleRequest = resolve;642});643644const request = new TestChatRequest('Continue');645const context = createChatContext(sessionId, false, request) as vscode.ChatContext & { history: []; readonly yieldRequested: boolean };646Object.defineProperty(context, 'history', {647value: [],648configurable: true,649});650Object.defineProperty(context, 'yieldRequested', {651get: () => yieldRequested,652configurable: true,653});654const stream = new MockChatResponseStream();655const token = disposables.add(new CancellationTokenSource()).token;656let resolved = false;657658const handlerPromise = (async () => {659await participant.createHandler()(request, context, stream, token);660resolved = true;661})();662await new Promise(resolve => setTimeout(resolve, 50));663expect(resolved).toBe(false);664665yieldRequested = true;666await new Promise(resolve => setTimeout(resolve, 600));667expect(resolved).toBe(true);668669resolveHandleRequest();670await handlerPromise;671});672673it('defers worktree handleRequestCompleted until all steering requests complete', async () => {674// Use an existing (non-untitled) session so both concurrent requests are guaranteed to675// resolve to the same SDK session and share the same pendingRequestBySession entry.676const sessionId = 'existing-worktree-session';677const sdkSession = new MockCliSdkSession(sessionId, new Date());678manager.sessions.set(sessionId, sdkSession);679680const worktreeProperties = {681autoCommit: true,682baseCommit: 'deadbeef',683branchName: 'test',684repositoryPath: `${sep}repo`,685worktreePath: `${sep}worktree`,686version: 1687} satisfies ChatSessionWorktreeProperties;688// FolderRepositoryManagerImpl.getFolderRepository checks worktreeService.getWorktreeProperties(sessionId)689// when the session ID is not an untitled ID.690(worktree.getWorktreeProperties as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(worktreeProperties);691// Simulate the session completing so the worktree commit path runs692TestCopilotCLISession.statusOverride = vscode.ChatSessionStatus.Completed;693694let resolveFirst!: () => void;695const firstDeferred = new Promise<void>(resolve => { resolveFirst = resolve; });696let resolveSecond!: () => void;697const secondDeferred = new Promise<void>(resolve => { resolveSecond = resolve; });698699TestCopilotCLISession.handleRequestHook = vi.fn((_request, input) => {700if (input.prompt === 'First') { return firstDeferred; }701return secondDeferred;702});703704const context = createChatContext(sessionId, false);705const sessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}` });706const stream = new MockChatResponseStream();707708const firstRequest = new TestChatRequest('First');709firstRequest.sessionResource = sessionResource;710const firstToken = disposables.add(new CancellationTokenSource()).token;711const firstPromise = participant.createHandler()(firstRequest, context, stream, firstToken);712713const secondRequest = new TestChatRequest('Second');714secondRequest.sessionResource = sessionResource;715const secondToken = disposables.add(new CancellationTokenSource()).token;716const secondPromise = participant.createHandler()(secondRequest, context, stream, secondToken);717718// Second (steering) request completes first — commit must NOT fire while first is still pending719resolveSecond();720await secondPromise;721expect(worktree.handleRequestCompleted).not.toHaveBeenCalled();722723// First request completes last — commit fires exactly once724resolveFirst();725await firstPromise;726expect(worktree.handleRequestCompleted).toHaveBeenCalledTimes(1);727});728729it('defers untitled session swap while a steering request is still pending', async () => {730(itemProvider.isNewSession as ReturnType<typeof vi.fn>).mockImplementation((sessionId: string) => sessionId.startsWith('untitled:'));731let resolveFirstRequest!: () => void;732const firstRequestDeferred = new Promise<void>(resolve => {733resolveFirstRequest = resolve;734});735let resolveSteeringRequest1!: () => void;736const steeringRequestDeferred1 = new Promise<void>(resolve => {737resolveSteeringRequest1 = resolve;738});739let resolveSteeringRequest2!: () => void;740const steeringRequestDeferred2 = new Promise<void>(resolve => {741resolveSteeringRequest2 = resolve;742});743let resolveSteeringRequest3!: () => void;744const steeringRequestDeferred3 = new Promise<void>(resolve => {745resolveSteeringRequest3 = resolve;746});747TestCopilotCLISession.handleRequestHook = vi.fn((_request, input) => {748if (input.prompt === 'First request') {749return firstRequestDeferred;750}751if (input.prompt === 'Steering request 1') {752return steeringRequestDeferred1;753}754if (input.prompt === 'Steering request 2') {755return steeringRequestDeferred2;756}757if (input.prompt === 'Steering request 3') {758return steeringRequestDeferred3;759}760return Promise.resolve();761});762763const context = createChatContext('untitled:temp-steering', true);764const steeringSessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: '/untitled:temp-steering' });765const stream = new MockChatResponseStream();766767const firstRequest = new TestChatRequest('First request');768firstRequest.sessionResource = steeringSessionResource;769const firstToken = disposables.add(new CancellationTokenSource()).token;770const firstPromise = participant.createHandler()(firstRequest, context, stream, firstToken);771772const secondRequest = new TestChatRequest('Steering request 1');773secondRequest.sessionResource = steeringSessionResource;774const secondToken = disposables.add(new CancellationTokenSource()).token;775const secondPromise = participant.createHandler()(secondRequest, context, stream, secondToken);776777const thirdRequest = new TestChatRequest('Steering request 2');778thirdRequest.sessionResource = steeringSessionResource;779const thirdToken = disposables.add(new CancellationTokenSource()).token;780const thirdPromise = participant.createHandler()(thirdRequest, context, stream, thirdToken);781782const fourthRequest = new TestChatRequest('Steering request 3');783fourthRequest.sessionResource = steeringSessionResource;784const fourthToken = disposables.add(new CancellationTokenSource()).token;785const fourthPromise = participant.createHandler()(fourthRequest, context, stream, fourthToken);786787resolveFirstRequest();788await firstPromise;789790expect(itemProvider.swap).not.toHaveBeenCalled();791792resolveSteeringRequest1();793await secondPromise;794795expect(itemProvider.swap).not.toHaveBeenCalled();796797resolveSteeringRequest2();798await thirdPromise;799800expect(itemProvider.swap).not.toHaveBeenCalled();801802const otherSessionId = 'existing-unblocked-session';803manager.sessions.set(otherSessionId, new MockCliSdkSession(otherSessionId, new Date()));804const otherContext = createChatContext(otherSessionId, false);805const otherRequest = new TestChatRequest('Request from other session');806otherRequest.sessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${otherSessionId}` });807const otherStream = new MockChatResponseStream();808const otherToken = disposables.add(new CancellationTokenSource()).token;809const otherRequestPromise = participant.createHandler()(otherRequest, otherContext, otherStream, otherToken);810const otherResult = await Promise.race([811Promise.resolve(otherRequestPromise).then(() => 'done'),812new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 75))813]);814expect(otherResult).toBe('done');815816expect(itemProvider.swap).not.toHaveBeenCalled();817818resolveSteeringRequest3();819await fourthPromise;820821expect(itemProvider.swap).toHaveBeenCalledTimes(1);822});823824it('hydrates invalid sessions from partial history and blocks follow-up requests', async () => {825const sessionId = 'invalid-session';826const invalidSessionService = new class extends FakeCopilotCLISessionService {827override getSession = vi.fn(async () => {828throw new Error('Failed to load session. Unknown event type: custom.unknown.');829});830override getChatHistory = vi.fn(async () => {831throw new Error('Failed to load session. Unknown event type: custom.unknown.');832}) as unknown as ICopilotCLISessionService['getChatHistory'];833override createSession = vi.fn(async () => {834throw new Error('createSession should not be called for invalid sessions');835});836override tryGetPartialSessionHistory: ICopilotCLISessionService['tryGetPartialSessionHistory'] = vi.fn(async () => ([{} as unknown as vscode.ChatRequestTurn, {} as unknown as vscode.ChatResponseTurn]));837}();838invalidSessionService.setTestSessionWorkingDirectory(sessionId, Uri.file(`${sep}workspace`));839const invalidContentProvider = new CopilotCLIChatSessionContentProvider(840itemProvider,841new NullCopilotCLIAgents(),842invalidSessionService,843worktree,844workspaceService,845new MockFileSystemService(),846git,847folderRepositoryManager,848configurationService,849customSessionTitleService,850new MockExtensionContext() as unknown as IVSCodeExtensionContext,851logService,852new (mock<IChatFolderMruService>())(),853);854const invalidParticipant = new CopilotCLIChatSessionParticipant(855invalidContentProvider,856promptResolver,857itemProvider,858cloudProvider,859undefined,860git,861models as unknown as ICopilotCLIModels,862new NullCopilotCLIAgents(),863invalidSessionService,864worktree,865worktreeCheckpointService,866workspaceFolderService,867telemetry,868logService,869disposables.add(new MockPromptsService()),870new class extends mock<IChatDelegationSummaryService>() {871override async summarize(_context: vscode.ChatContext, _token: vscode.CancellationToken): Promise<string | undefined> {872return undefined;873}874}(),875folderRepositoryManager,876configurationService,877sdk,878new MockChatSessionMetadataStore(),879customSessionTitleService,880new (mock<IOctoKitService>())(),881);882const sessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}` });883const contentToken = disposables.add(new CancellationTokenSource()).token;884885const sessionContent = await invalidContentProvider.provideChatSessionContentForExistingSession(sessionResource, contentToken);886887expect(sessionContent.history).toHaveLength(2);888expect(invalidSessionService.tryGetPartialSessionHistory).toHaveBeenCalledWith(sessionId);889890(invalidSessionService.getSession as ReturnType<typeof vi.fn>).mockClear();891(invalidSessionService.createSession as ReturnType<typeof vi.fn>).mockClear();892(invalidSessionService.tryGetPartialSessionHistory as ReturnType<typeof vi.fn>).mockClear();893const request = new TestChatRequest('Continue from VS Code');894const context = createChatContext(sessionId, false, request);895const stream = new MockChatResponseStream();896const requestToken = disposables.add(new CancellationTokenSource()).token;897898await invalidParticipant.createHandler()(request, context, stream, requestToken);899900const output = stream.output.join('\n');901expect(output).toContain('Failed loading this session');902expect(output).toContain('report an issue');903// The error message is appended via MarkdownString.appendText which encodes spaces as 904expect(output).toContain('Failed to load session');905expect(invalidSessionService.getSession).not.toHaveBeenCalled();906expect(invalidSessionService.createSession).not.toHaveBeenCalled();907});908909it('handles /delegate command for existing session (no session.handleRequest)', async () => {910const sessionId = 'existing-123';911const sdkSession = new MockCliSdkSession(sessionId, new Date());912manager.sessions.set(sessionId, sdkSession);913914git.activeRepository = { get: () => ({ changes: { indexChanges: [{ path: 'file.ts' }] } }) } as unknown as IGitService['activeRepository'];915const request = new TestChatRequest('Build feature');916request.command = 'delegate';917const context = createChatContext(sessionId, false, request);918const stream = new MockChatResponseStream();919const token = disposables.add(new CancellationTokenSource()).token;920expect(cliSessions.length).toBe(0);921922await participant.createHandler()(request, context, stream, token);923924expect(cliSessions.length).toBe(1);925expect(cliSessions[0].sessionId).toBe(sessionId);926expect(cliSessions[0].requests.length).toBe(0);927expect(sdkSession.emittedEvents.length).toBe(2);928expect(sdkSession.emittedEvents[0].event).toBe('user.message');929expect(sdkSession.emittedEvents[0].content).toBe('/delegate Build feature');930expect(sdkSession.emittedEvents[1].event).toBe('assistant.message');931expect(sdkSession.emittedEvents[1].content).toContain('pr://1');932// Uncommitted changes warning surfaced933// Warning should appear (we emitted stream.warning). The mock stream only records markdown.934// Delegate path adds assistant PR metadata; ensure output contains PR metadata tag instead of relying on warning capture.935expect(sdkSession.emittedEvents[1].content).toMatch(/<pr_metadata uri="pr:\/\/1"/);936expect(cloudProvider.delegate).toHaveBeenCalled();937});938939it('handles /delegate command from another chat (has uncommitted changes and user copies changes)', async () => {940expect(manager.sessions.size).toBe(0);941const repoContext = { rootUri: Uri.file(`${sep}workspace`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext;942git.activeRepository = { get: () => repoContext } as unknown as IGitService['activeRepository'];943git.setRepo(repoContext);944tools.nextConfirmationButton = 'Copy Changes';945const request = new TestChatRequest('/delegate Build feature');946const context = { chatSessionContext: undefined } as vscode.ChatContext;947const stream = new MockChatResponseStream();948const token = disposables.add(new CancellationTokenSource()).token;949950await participant.createHandler()(request, context, stream, token);951952// With the awaitable confirmation, the session should be created in a single request953expect(manager.sessions.size).toBe(1);954const delegateCallArgs = (tools.invokeTool as unknown as ReturnType<typeof vi.fn>).mock.calls[0];955expect(delegateCallArgs[0]).toBe('vscode_get_modified_files_confirmation');956expect(delegateCallArgs[1].input.title).toBe('Delegate to Copilot CLI');957expect(delegateCallArgs[1].input.modifiedFiles).toHaveLength(1);958expect(delegateCallArgs[1].input.modifiedFiles[0].uri.toString()).toBe(Uri.file(`${sep}workspace${sep}file.ts`).toString());959expect(delegateCallArgs[2]).toBe(token);960});961962it('handles /delegate command from another chat without active repository', async () => {963expect(manager.sessions.size).toBe(0);964const request = new TestChatRequest('/delegate Build feature');965const context = { chatSessionContext: undefined } as vscode.ChatContext;966const stream = new MockChatResponseStream();967const token = disposables.add(new CancellationTokenSource()).token;968969await participant.createHandler()(request, context, stream, token);970971expect(manager.sessions.size).toBe(1);972// No confirmation should be invoked when there are no uncommitted changes973expect(tools.invokeTool).not.toHaveBeenCalled();974});975976it('handles /delegate command for new session without uncommitted changes', async () => {977expect(manager.sessions.size).toBe(0);978git.activeRepository = { get: () => ({ changes: { indexChanges: [], workingTree: [] } }) } as unknown as IGitService['activeRepository'];979const request = new TestChatRequest('Build feature');980request.command = 'delegate';981const context = createChatContext('existing-delegate', true, request);982const stream = new MockChatResponseStream();983const token = disposables.add(new CancellationTokenSource()).token;984985await participant.createHandler()(request, context, stream, token);986987expect(manager.sessions.size).toBe(1);988const sdkSession = Array.from(manager.sessions.values())[0];989expect(cloudProvider.delegate).toHaveBeenCalled();990// PR metadata recorded991expect(sdkSession.emittedEvents.length).toBe(2);992expect(sdkSession.emittedEvents[0].event).toBe('user.message');993expect(sdkSession.emittedEvents[0].content).toBe('/delegate Build feature');994expect(sdkSession.emittedEvents[1].event).toBe('assistant.message');995expect(sdkSession.emittedEvents[1].content).toContain('pr://1');996// Warning should appear (we emitted stream.warning). The mock stream only records markdown.997// Delegate path adds assistant PR metadata; ensure output contains PR metadata tag instead of relying on warning capture.998expect(sdkSession.emittedEvents[1].content).toMatch(/<pr_metadata uri="pr:\/\/1"/);999});10001001it('starts a new chat session and submits the request', async () => {1002const request = new TestChatRequest('Push this');1003(request as Record<string, any>).model = mockLanguageModelChat;1004const context = { chatSessionContext: undefined, chatSummary: undefined } as unknown as vscode.ChatContext;1005const stream = new MockChatResponseStream();1006const token = disposables.add(new CancellationTokenSource()).token;1007const summarySpy = vi.spyOn(summarizer, 'provideChatSummary');10081009await participant.createHandler()(request, context, stream, token);10101011expect(manager.sessions.size).toBe(1);1012expect(summarySpy).toHaveBeenCalledTimes(0);1013// Delegation creates the session and fires executeCommand (fire-and-forget).1014// The request is processed asynchronously when VS Code opens the session.1015expect(mockExecuteCommand).toHaveBeenCalledWith(1016'workbench.action.chat.openSessionWithPrompt.copilotcli',1017expect.objectContaining({1018prompt: 'Push this',1019})1020);1021});10221023it('handles existing session with acceptedConfirmationData (no longer triggers cloud delegation)', async () => {1024// With the new flow, acceptedConfirmationData is no longer used for uncommitted changes.1025// Existing sessions proceed directly to handleRequest without confirmation flow.1026const sessionId = 'existing-confirm';1027const sdkSession = new MockCliSdkSession(sessionId, new Date());1028manager.sessions.set(sessionId, sdkSession);1029const request = new TestChatRequest('my prompt');1030const context = createChatContext(sessionId, false, request);1031const stream = new MockChatResponseStream();1032const token = disposables.add(new CancellationTokenSource()).token;10331034await participant.createHandler()(request, context, stream, token);10351036// Should call session.handleRequest normally1037expect(cliSessions.length).toBe(1);1038expect(cliSessions[0].requests.length).toBe(1);1039expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'my prompt' });1040});10411042it('handles existing session with rejectedConfirmationData (proceeds normally)', async () => {1043// With the new flow, rejectedConfirmationData is no longer used for uncommitted changes.1044const sessionId = 'existing-confirm-reject';1045const sdkSession = new MockCliSdkSession(sessionId, new Date());1046manager.sessions.set(sessionId, sdkSession);1047const request = new TestChatRequest('Apply');1048const context = createChatContext(sessionId, false, request);1049const stream = new MockChatResponseStream();1050const token = disposables.add(new CancellationTokenSource()).token;10511052await participant.createHandler()(request, context, stream, token);10531054// Should proceed normally (no cloud delegation)1055expect(cliSessions.length).toBe(1);1056expect(cliSessions[0].requests.length).toBe(1);1057expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Apply' });1058});10591060it('handles existing session with unknown step acceptedConfirmationData (proceeds normally)', async () => {1061const sessionId = 'existing-confirm-unknown';1062const sdkSession = new MockCliSdkSession(sessionId, new Date());1063manager.sessions.set(sessionId, sdkSession);1064const request = new TestChatRequest('Apply');1065const context = createChatContext(sessionId, false, request);1066const stream = new MockChatResponseStream();1067const token = disposables.add(new CancellationTokenSource()).token;10681069await participant.createHandler()(request, context, stream, token);10701071// Should proceed normally1072expect(cliSessions.length).toBe(1);1073expect(cliSessions[0].requests.length).toBe(1);1074});10751076it('prompts for uncommitted changes action for untitled session with uncommitted changes', async () => {1077git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];1078git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);1079// Set up untitled session folder so getFolderRepository returns repository info1080folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));1081// User selects Copy Changes1082tools.nextConfirmationButton = 'Copy Changes';1083const request = new TestChatRequest('Fix the bug');1084const context = createChatContext('untitled:temp-new', true, request);1085const stream = new MockChatResponseStream();1086const token = disposables.add(new CancellationTokenSource()).token;10871088await participant.createHandler()(request, context, stream, token);10891090// Session should be created in one request (no separate confirmation round-trip)1091expect(cliSessions.length).toBe(1);1092expect(cliSessions[0].requests.length).toBe(1);1093expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });1094// Verify confirmation tool was invoked with the right title1095const confirmCallArgs = (tools.invokeTool as unknown as ReturnType<typeof vi.fn>).mock.calls[0];1096expect(confirmCallArgs[0]).toBe('vscode_get_modified_files_confirmation');1097expect(confirmCallArgs[1].input.title).toBe('Uncommitted Changes');1098expect(confirmCallArgs[1].input.modifiedFiles).toHaveLength(1);1099expect(confirmCallArgs[1].input.modifiedFiles[0].uri.toString()).toBe(Uri.file(`${sep}repo${sep}file.ts`).toString());1100expect(confirmCallArgs[2]).toBe(token);1101});11021103it('uses request prompt directly when user accepts uncommitted changes confirmation', async () => {1104git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];1105git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);1106folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));1107tools.nextConfirmationButton = 'Copy Changes';11081109const request = new TestChatRequest('Fix the bug');1110const context = createChatContext('untitled:temp-new', true, request);1111const stream = new MockChatResponseStream();1112const token = disposables.add(new CancellationTokenSource()).token;11131114await participant.createHandler()(request, context, stream, token);11151116// Should create session and use request.prompt directly1117expect(cliSessions.length).toBe(1);1118expect(cliSessions[0].requests.length).toBe(1);1119expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });1120// Verify promptResolver was called without override prompt1121expect(promptResolver.resolvePrompt).toHaveBeenCalled();1122expect((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1]).toBeUndefined();1123});11241125it('uses request prompt for session label when swapping untitled session', async () => {1126git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];1127git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);1128folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));1129tools.nextConfirmationButton = 'Move Changes';11301131const request = new TestChatRequest('Implement new feature');1132const context = createChatContext('untitled:temp-new', true, request);1133const stream = new MockChatResponseStream();1134const token = disposables.add(new CancellationTokenSource()).token;11351136await participant.createHandler()(request, context, stream, token);11371138// Should swap with request.prompt as label1139expect(itemProvider.swap).toHaveBeenCalled();1140const swapCall = (itemProvider.swap as unknown as ReturnType<typeof vi.fn>).mock.calls[0];1141expect(swapCall[1].label).toBe('Implement new feature');1142});11431144it('passes empty references array to resolvePrompt after confirmation', async () => {1145git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];1146git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);1147folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));1148tools.nextConfirmationButton = 'Copy Changes';11491150const request = new TestChatRequest('Fix the bug');1151const context = createChatContext('untitled:temp-new', true, request);1152const stream = new MockChatResponseStream();1153const token = disposables.add(new CancellationTokenSource()).token;11541155await participant.createHandler()(request, context, stream, token);11561157// Should pass empty array to resolvePrompt (no metadata to recover from)1158expect(promptResolver.resolvePrompt).toHaveBeenCalled();1159const resolvePromptCall = (promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0];1160expect(resolvePromptCall[2]).toEqual([]);1161});11621163it('returns empty when user cancels untitled session confirmation', async () => {1164git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];1165git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);1166folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));1167// User clicks Cancel1168tools.nextConfirmationButton = 'Cancel';11691170const request = new TestChatRequest('Fix the bug');1171const context = createChatContext('untitled:temp-new', true, request);1172const stream = new MockChatResponseStream();1173const token = disposables.add(new CancellationTokenSource()).token;11741175await participant.createHandler()(request, context, stream, token);11761177// Should not create session1178expect(cliSessions.length).toBe(0);1179expect(itemProvider.swap).not.toHaveBeenCalled();1180});11811182it('does not prompt for confirmation for untitled session without uncommitted changes', async () => {1183git.activeRepository = { get: () => ({ changes: { indexChanges: [], workingTree: [] } }) } as unknown as IGitService['activeRepository'];11841185const request = new TestChatRequest('Fix the bug');1186const context = createChatContext('temp-new', true, request);1187const stream = new MockChatResponseStream();1188const token = disposables.add(new CancellationTokenSource()).token;11891190await participant.createHandler()(request, context, stream, token);11911192// Should create session directly without confirmation1193expect(tools.invokeTool).not.toHaveBeenCalled();1194expect(cliSessions.length).toBe(1);1195expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });1196});11971198it('does not prompt for confirmation for existing (non-untitled) session with uncommitted changes', async () => {1199const sessionId = 'existing-123';1200const sdkSession = new MockCliSdkSession(sessionId, new Date());1201manager.sessions.set(sessionId, sdkSession);1202git.activeRepository = { get: () => ({ changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository'];12031204const request = new TestChatRequest('Continue work');1205const context = createChatContext(sessionId, false, request);1206const stream = new MockChatResponseStream();1207const token = disposables.add(new CancellationTokenSource()).token;12081209await participant.createHandler()(request, context, stream, token);12101211// Should not prompt for confirmation for existing sessions1212expect(tools.invokeTool).not.toHaveBeenCalled();1213expect(cliSessions.length).toBe(1);1214expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Continue work' });1215});12161217it('reuses untitled session without uncommitted changes instead of creating new session', async () => {1218git.activeRepository = { get: () => ({ changes: { indexChanges: [], workingTree: [] } }) } as unknown as IGitService['activeRepository'];12191220// First request creates the session1221const request1 = new TestChatRequest('First request');1222const context1 = createChatContext('temp-new', true, request1);1223const stream1 = new MockChatResponseStream();1224const token1 = disposables.add(new CancellationTokenSource()).token;12251226await participant.createHandler()(request1, context1, stream1, token1);1227expect(cliSessions.length).toBe(1);1228const firstSessionId = cliSessions[0].sessionId;12291230// Second request should reuse the same session (now it's not untitled anymore after first request)1231const request2 = new TestChatRequest('Second request');1232const context2 = createChatContext(firstSessionId, false, request2);1233const stream2 = new MockChatResponseStream();1234const token2 = disposables.add(new CancellationTokenSource()).token;12351236await participant.createHandler()(request2, context2, stream2, token2);12371238// Session wrapper can be recreated, but the SDK session should be reused.1239expect(manager.sessions.size).toBe(1);1240expect(new Set(cliSessions.map(s => s.sessionId))).toEqual(new Set([firstSessionId]));1241expect(cliSessions.reduce((count, s) => count + s.requests.length, 0)).toBe(2);1242expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request' });1243expect(cliSessions.at(-1)?.requests.at(-1)?.input).toEqual({ prompt: 'Second request' });1244});12451246it('reuses untitled session after confirmation without creating new session', async () => {1247git.activeRepository = { get: () => ({ remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];1248git.setRepo({ rootUri: Uri.file(`${sep}workspace`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);1249// Set up untitled session folder so getFolderRepository returns repository info (for uncommitted changes check)1250folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}workspace`));1251// User selects Copy Changes via the tools confirmation1252tools.nextConfirmationButton = 'Copy Changes';12531254// First request creates the session (with confirmation handled inline)1255const request1 = new TestChatRequest('First request');1256const context1 = createChatContext('untitled:temp-new', true, request1);1257const stream1 = new MockChatResponseStream();1258const token1 = disposables.add(new CancellationTokenSource()).token;12591260await participant.createHandler()(request1, context1, stream1, token1);12611262// Session should be created1263expect(cliSessions.length).toBe(1);1264const firstSessionId = cliSessions[0].sessionId;1265expect(cliSessions[0].requests.length).toBe(1);1266expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request' });12671268// Second request should reuse the same session1269const request2 = new TestChatRequest('Second request');1270const context2 = createChatContext(firstSessionId, false, request2);1271const stream2 = new MockChatResponseStream();1272const token2 = disposables.add(new CancellationTokenSource()).token;12731274await participant.createHandler()(request2, context2, stream2, token2);12751276// Session wrapper can be recreated, but the SDK session should be reused.1277expect(manager.sessions.size).toBe(1);1278expect(new Set(cliSessions.map(s => s.sessionId))).toEqual(new Set([firstSessionId]));1279expect(cliSessions.reduce((count, s) => count + s.requests.length, 0)).toBe(2);1280expect(cliSessions.at(-1)?.requests.at(-1)?.input).toEqual({ prompt: 'Second request' });1281});12821283describe('Authorization check', () => {1284it('throws when auth token is empty and no proxy URL configured', async () => {1285(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockResolvedValue({ type: 'token', token: '', host: 'https://github.com' });12861287const request = new TestChatRequest('Say hi');1288const context = createChatContext('temp-new', true, request);1289const stream = new MockChatResponseStream();1290const token = disposables.add(new CancellationTokenSource()).token;12911292await expect(participant.createHandler()(request, context, stream, token)).rejects.toThrow('Authorization failed');1293expect(cliSessions.length).toBe(0);1294});12951296it('proceeds normally when auth token is valid', async () => {1297(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockResolvedValue({ type: 'token', token: 'valid-token', host: 'https://github.com' });12981299const request = new TestChatRequest('Say hi');1300const context = createChatContext('temp-new', true, request);1301const stream = new MockChatResponseStream();1302const token = disposables.add(new CancellationTokenSource()).token;13031304await participant.createHandler()(request, context, stream, token);13051306expect(cliSessions.length).toBe(1);1307expect(cliSessions[0].requests.length).toBe(1);1308});13091310it('proceeds when auth type is not token even if token is empty', async () => {1311(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockResolvedValue({ type: 'oauth', token: '', host: 'https://github.com' });13121313const request = new TestChatRequest('Say hi');1314const context = createChatContext('temp-new', true, request);1315const stream = new MockChatResponseStream();1316const token = disposables.add(new CancellationTokenSource()).token;13171318await participant.createHandler()(request, context, stream, token);13191320expect(cliSessions.length).toBe(1);1321expect(cliSessions[0].requests.length).toBe(1);1322});13231324it('throws when getAuthInfo rejects', async () => {1325(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('network error'));13261327const request = new TestChatRequest('Say hi');1328const context = createChatContext('temp-new', true, request);1329const stream = new MockChatResponseStream();1330const token = disposables.add(new CancellationTokenSource()).token;13311332await expect(participant.createHandler()(request, context, stream, token)).rejects.toThrow('Authorization failed');1333expect(cliSessions.length).toBe(0);1334});1335});13361337describe('Repository option locking behavior', () => {1338it('locks repository option on request start for untitled sessions', async () => {1339// Setup folder repository manager to return valid folder data1340const sessionId = 'untitled:temp-lock';1341const mockGetFolderRepository = vi.fn(async () => ({1342folder: Uri.file(`${sep}workspace`),1343trusted: true1344}));1345(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;13461347const request = new TestChatRequest('Say hi');1348const context = createChatContext(sessionId, true, request);1349const stream = new MockChatResponseStream();1350const token = disposables.add(new CancellationTokenSource()).token;13511352await participant.createHandler()(request, context, stream, token);13531354// Verify lock was called with locked: true before other operations1355const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1356const lockCalls = allCalls.filter(1357call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1358);1359expect(lockCalls.length).toBeGreaterThan(0);1360});13611362it('does not lock repository option for existing (non-untitled) sessions', async () => {1363const sessionId = 'existing-lock-123';1364const sdkSession = new MockCliSdkSession(sessionId, new Date());1365manager.sessions.set(sessionId, sdkSession);13661367const request = new TestChatRequest('Continue work');1368const context = createChatContext(sessionId, false, request);1369const stream = new MockChatResponseStream();1370const token = disposables.add(new CancellationTokenSource()).token;13711372await participant.createHandler()(request, context, stream, token);13731374// Verify lock was NOT called (no calls with locked flag)1375const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1376const lockCalls = allCalls.filter(1377call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1378);1379expect(lockCalls.length).toBe(0);1380});13811382it('unlocks repository option when user rejects trust check', async () => {1383const sessionId = 'untitled:temp-trust-fail';1384// Mock folderRepositoryManager to simulate trust rejection1385const mockGetFolderRepository = vi.fn(async () => ({1386trusted: false,1387folder: Uri.file(`${sep}workspace`)1388}));1389(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;1390// Trust rejection now happens in initializeFolderRepository (not in the removed hasUncommittedChangesToHandleInRequest)1391const mockInitializeFolderRepository = vi.fn(async () => ({1392trusted: false,1393folder: Uri.file(`${sep}workspace`),1394repository: undefined,1395worktree: undefined,1396worktreeProperties: undefined1397}));1398(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;13991400const request = new TestChatRequest('Say hi');1401const context = createChatContext(sessionId, true, request);1402const stream = new MockChatResponseStream();1403const token = disposables.add(new CancellationTokenSource()).token;14041405await participant.createHandler()(request, context, stream, token);14061407// Verify lock was called1408const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1409const lockCalls = allCalls.filter(1410call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1411);1412expect(lockCalls.length).toBeGreaterThan(0);14131414// Verify unlock was called (value is string with no locked flag)1415const unlockCalls = allCalls.filter(1416call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')1417);1418expect(unlockCalls.length).toBeGreaterThan(0);14191420// Verify no session was created due to trust rejection1421expect(cliSessions.length).toBe(0);1422});14231424it('does not unlock repository option when user cancels confirmation', async () => {1425const sessionId = 'untitled:temp-cancel';1426git.activeRepository = {1427get: () => ({1428rootUri: Uri.file(`${sep}repo`),1429changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] }1430})1431} as unknown as IGitService['activeRepository'];1432git.setRepo({1433rootUri: Uri.file(`${sep}repo`),1434changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] }1435} as unknown as RepoContext);14361437const mockGetFolderRepository = vi.fn(async () => ({1438repository: { rootUri: Uri.file(`${sep}repo`), kind: 'repository' } as unknown as RepoContext,1439folder: Uri.file(`${sep}repo`),1440trusted: true1441}));1442(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;14431444// User cancels the confirmation1445tools.nextConfirmationButton = 'Cancel';14461447const request = new TestChatRequest('Fix bug');1448const context = createChatContext(sessionId, true, request);1449const stream = new MockChatResponseStream();1450const token = disposables.add(new CancellationTokenSource()).token;14511452await participant.createHandler()(request, context, stream, token);14531454// Verify lock was called1455const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1456const lockCalls = allCalls.filter(1457call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1458);1459expect(lockCalls.length).toBeGreaterThan(0);14601461// After cancel, there should be no unlock calls (repository option remains locked)1462const unlockCalls = allCalls.filter(1463call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')1464);1465expect(unlockCalls.length).toBe(0);14661467// No session created due to cancellation1468expect(cliSessions.length).toBe(0);1469});14701471it('does not unlock repository option when session creation fails', async () => {1472const sessionId = 'untitled:temp-fail';1473const mockGetFolderRepository = vi.fn(async () => ({1474folder: Uri.file(`${sep}workspace`),1475trusted: true1476}));1477(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;14781479const request = new TestChatRequest('Say hi');1480const context = createChatContext(sessionId, true, request);1481const stream = new MockChatResponseStream();1482const token = disposables.add(new CancellationTokenSource()).token;14831484// Mock sessionService.createSession to return null1485const originalCreateSession = sessionService.createSession;1486(sessionService.createSession as any) = vi.fn(async () => undefined);14871488try {1489await participant.createHandler()(request, context, stream, token);1490} finally {1491(sessionService.createSession as any) = originalCreateSession;1492}14931494// Verify lock was called1495const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1496const lockCalls = allCalls.filter(1497call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1498);1499expect(lockCalls.length).toBeGreaterThan(0);15001501// Verify unlock was NOT called on failure (session creation failed but workspace was trusted)1502const unlockCalls = allCalls.filter(1503call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')1504);1505expect(unlockCalls.length).toBe(0);15061507// No session created due to failure1508expect(cliSessions.length).toBe(0);1509});15101511it('keeps repository option locked throughout successful request flow', async () => {1512const sessionId = 'untitled:temp-success';1513const mockGetFolderRepository = vi.fn(async () => ({1514folder: Uri.file(`${sep}workspace`),1515trusted: true1516}));1517(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;15181519const request = new TestChatRequest('Say hi');1520const context = createChatContext(sessionId, true, request);1521const stream = new MockChatResponseStream();1522const token = disposables.add(new CancellationTokenSource()).token;15231524await participant.createHandler()(request, context, stream, token);15251526// Verify lock was called1527const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1528const lockCalls = allCalls.filter(1529call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1530);1531expect(lockCalls.length).toBeGreaterThan(0);15321533// Verify unlock was NOT called on successful completion1534const unlockCalls = allCalls.filter(1535call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')1536);1537expect(unlockCalls.length).toBe(0);15381539// Verify session was created1540expect(cliSessions.length).toBe(1);1541});15421543it('displays repo directory name (not parent workspace folder name) for sub-directory git repos in multi-root workspaces', async () => {1544// Bug scenario: multi-root workspace with folders A, B where B has sub-directories repo1, repo2.1545// When user selects repo2, the locked dropdown should display "repo2", not "B".1546const sessionId = 'untitled:temp-multiroot';1547const repoUri = Uri.file(`${sep}workspaces${sep}B${sep}repo2`);1548const mockGetFolderRepository = vi.fn(async () => ({1549folder: repoUri,1550repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,1551trusted: true1552}));1553(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;15541555const request = new TestChatRequest('Say hi');1556const context = createChatContext(sessionId, true, request);1557const stream = new MockChatResponseStream();1558const token = disposables.add(new CancellationTokenSource()).token;15591560await participant.createHandler()(request, context, stream, token);15611562// Verify the locked option uses the repo name "repo2", not the parent workspace folder "B"1563const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1564const lockCalls = allCalls.filter(1565call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1566);1567expect(lockCalls.length).toBeGreaterThan(0);1568// When repository is available, toRepositoryOptionItem derives name from the repo URI path1569const repoLockUpdate = lockCalls.flatMap(call => call[1]).find(1570(update: any) => update.optionId === 'repository' && update.value?.locked === true1571);1572expect(repoLockUpdate.value.name).toBe('repo2');1573expect(repoLockUpdate.value.id).toBe(repoUri.fsPath);1574});15751576it('displays folder basename (not workspace folder name) when locking a non-repo sub-directory folder', async () => {1577// When the selected folder is NOT a git repo but is a sub-directory of a workspace folder,1578// the locked dropdown should display the folder's basename, not the workspace folder name.1579const sessionId = 'untitled:temp-subfolder';1580const folderUri = Uri.file(`${sep}workspaces${sep}B${sep}subfolder`);1581const mockGetFolderRepository = vi.fn(async () => ({1582folder: folderUri,1583repository: undefined,1584trusted: true1585}));1586(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;15871588const request = new TestChatRequest('Say hi');1589const context = createChatContext(sessionId, true, request);1590const stream = new MockChatResponseStream();1591const token = disposables.add(new CancellationTokenSource()).token;15921593await participant.createHandler()(request, context, stream, token);15941595// Verify the locked option uses basename "subfolder", not workspace folder name "B"1596const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1597const lockCalls = allCalls.filter(1598call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1599);1600expect(lockCalls.length).toBeGreaterThan(0);1601const folderLockUpdate = lockCalls.flatMap(call => call[1]).find(1602(update: any) => update.optionId === 'repository' && update.value?.locked === true1603);1604expect(folderLockUpdate.value.name).toBe('subfolder');1605expect(folderLockUpdate.value.id).toBe(folderUri.fsPath);1606// Non-repo folder should use folder icon1607expect(folderLockUpdate.value.icon.id).toBe('folder');1608});16091610it('uses repo icon for repository and folder icon for plain folder when locking', async () => {1611// Verify icon differentiation: repo gets 'repo' icon, plain folder gets 'folder' icon1612const sessionId = 'untitled:temp-icon';1613const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);1614const mockGetFolderRepository = vi.fn(async () => ({1615folder: repoUri,1616repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,1617trusted: true1618}));1619(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;16201621const request = new TestChatRequest('Say hi');1622const context = createChatContext(sessionId, true, request);1623const stream = new MockChatResponseStream();1624const token = disposables.add(new CancellationTokenSource()).token;16251626await participant.createHandler()(request, context, stream, token);16271628const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1629const repoLockUpdate = allCalls.flatMap(call => call[1]).find(1630(update: any) => update.optionId === 'repository' && update.value?.locked === true1631);1632// Repository should use 'repo' icon1633expect(repoLockUpdate.value.icon.id).toBe('repo');1634});16351636it('eagerly re-locks repo option with accurate info after session creation for untitled sessions', async () => {1637// The new code at line ~735 fires `void this.lockRepoOptionForSession(context, token)`1638// after session creation to update the locked dropdown with more accurate info.1639const sessionId = 'untitled:temp-eager-lock';1640const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);1641const mockGetFolderRepository = vi.fn(async () => ({1642folder: repoUri,1643repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,1644trusted: true1645}));1646(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;16471648const request = new TestChatRequest('Say hi');1649const context = createChatContext(sessionId, true, request);1650const stream = new MockChatResponseStream();1651const token = disposables.add(new CancellationTokenSource()).token;16521653await participant.createHandler()(request, context, stream, token);16541655// There should be multiple lock calls: one initial lock and one eager re-lock after session creation.1656// The eager lock should contain the updated repo information.1657const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1658const lockCalls = allCalls.filter(1659call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)1660);1661// Expect at least 2 lock calls (initial lock + eager re-lock after session creation)1662expect(lockCalls.length).toBeGreaterThanOrEqual(2);16631664// The last lock call should have the accurate repo information1665const lastLockCall = lockCalls[lockCalls.length - 1];1666const lastLockUpdate = lastLockCall[1].find(1667(update: any) => update.optionId === 'repository' && update.value?.locked === true1668);1669expect(lastLockUpdate.value.name).toBe('myrepo');1670expect(lastLockUpdate.value.id).toBe(repoUri.fsPath);1671});16721673it('locks with submodule/archive icon for submodule repositories', async () => {1674const sessionId = 'untitled:temp-submodule';1675const repoUri = Uri.file(`${sep}workspace${sep}submodule-repo`);1676const mockGetFolderRepository = vi.fn(async () => ({1677folder: repoUri,1678repository: { rootUri: repoUri, kind: 'submodule' } as unknown as RepoContext,1679trusted: true1680}));1681(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;16821683const request = new TestChatRequest('Say hi');1684const context = createChatContext(sessionId, true, request);1685const stream = new MockChatResponseStream();1686const token = disposables.add(new CancellationTokenSource()).token;16871688await participant.createHandler()(request, context, stream, token);16891690const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1691const repoLockUpdate = allCalls.flatMap(call => call[1]).find(1692(update: any) => update.optionId === 'repository' && update.value?.locked === true1693);1694// Submodule repositories should use 'archive' icon (not 'repo')1695expect(repoLockUpdate.value.icon.id).toBe('archive');1696expect(repoLockUpdate.value.name).toBe('submodule-repo');1697});16981699it('locks branch option alongside repository option when branch is selected', async () => {1700const sessionId = 'untitled:temp-branch-lock';1701const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);1702const mockGetFolderRepository = vi.fn(async () => ({1703folder: repoUri,1704repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,1705trusted: true1706}));1707(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;17081709// Simulate branch selection via initial options1710const request = new TestChatRequest('Say hi');1711const context = createChatContext(sessionId, true, request);1712(context.chatSessionContext as any).initialSessionOptions = [1713{ optionId: 'branch', value: 'feature-branch' }1714];1715const stream = new MockChatResponseStream();1716const token = disposables.add(new CancellationTokenSource()).token;17171718await participant.createHandler()(request, context, stream, token);17191720const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1721// Find a lock call that includes both repo and branch locking1722const branchLockCalls = allCalls.filter(1723call => call[1].some((update: any) => update.optionId === 'branch' && update.value?.locked === true)1724);1725expect(branchLockCalls.length).toBeGreaterThan(0);17261727const branchLockUpdate = branchLockCalls.flatMap(call => call[1]).find(1728(update: any) => update.optionId === 'branch' && update.value?.locked === true1729);1730expect(branchLockUpdate.value.name).toBe('feature-branch');1731expect(branchLockUpdate.value.icon.id).toBe('git-branch');1732});17331734it('does not lock branch option when no branch is selected', async () => {1735const sessionId = 'untitled:temp-no-branch-lock';1736const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);1737const mockGetFolderRepository = vi.fn(async () => ({1738folder: repoUri,1739repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,1740trusted: true1741}));1742(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;17431744const request = new TestChatRequest('Say hi');1745const context = createChatContext(sessionId, true, request);1746const stream = new MockChatResponseStream();1747const token = disposables.add(new CancellationTokenSource()).token;17481749await participant.createHandler()(request, context, stream, token);17501751const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1752const branchLockCalls = allCalls.filter(1753call => call[1].some((update: any) => update.optionId === 'branch')1754);1755expect(branchLockCalls.length).toBe(0);1756});17571758it('unlocks branch option alongside repository option when trust is denied', async () => {1759const sessionId = 'untitled:temp-branch-unlock';1760const mockGetFolderRepository = vi.fn(async () => ({1761trusted: false,1762folder: Uri.file(`${sep}workspace`)1763}));1764(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;1765const mockInitializeFolderRepository = vi.fn(async () => ({1766trusted: false,1767folder: Uri.file(`${sep}workspace`),1768repository: undefined,1769worktree: undefined,1770worktreeProperties: undefined1771}));1772(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;17731774// Simulate having a branch selected before running1775const request = new TestChatRequest('Say hi');1776const context = createChatContext(sessionId, true, request);1777(context.chatSessionContext as any).initialSessionOptions = [1778{ optionId: 'branch', value: 'my-branch' }1779];1780const stream = new MockChatResponseStream();1781const token = disposables.add(new CancellationTokenSource()).token;17821783await participant.createHandler()(request, context, stream, token);17841785const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;1786// Find unlock calls (value is string, not an object with locked flag)1787const branchUnlockCalls = allCalls.filter(1788call => call[1].some((update: any) => update.optionId === 'branch' && typeof update.value === 'string')1789);1790expect(branchUnlockCalls.length).toBeGreaterThan(0);1791});17921793it('passes branch to initializeFolderRepository when branch is set via initial options', async () => {1794const sessionId = 'untitled:temp-branch-pass';1795const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);1796const mockInitializeFolderRepository = vi.fn(async () => ({1797folder: repoUri,1798repository: undefined,1799worktree: undefined,1800worktreeProperties: undefined,1801trusted: true,1802cancelled: false,1803}));1804(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;18051806const request = new TestChatRequest('Say hi');1807const context = createChatContext(sessionId, true, request);1808// Simulate branch being pre-selected (e.g. by provideChatSessionContent auto-selecting default branch)1809(context.chatSessionContext as any).initialSessionOptions = [1810{ optionId: 'branch', value: 'feature-branch' }1811];1812const stream = new MockChatResponseStream();1813const token = disposables.add(new CancellationTokenSource()).token;18141815await participant.createHandler()(request, context, stream, token);18161817expect(mockInitializeFolderRepository).toHaveBeenCalled();1818const [, options] = mockInitializeFolderRepository.mock.calls[0] as unknown as Parameters<typeof folderRepositoryManager.initializeFolderRepository>;1819expect(options.branch).toBe('feature-branch');1820});18211822it('passes undefined branch to initializeFolderRepository when no branch is selected', async () => {1823const sessionId = 'untitled:temp-no-branch-pass';1824const mockInitializeFolderRepository = vi.fn(async () => ({1825folder: Uri.file(`${sep}workspace`),1826repository: undefined,1827worktree: undefined,1828worktreeProperties: undefined,1829trusted: true,1830cancelled: false,1831}));1832(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;18331834const request = new TestChatRequest('Say hi');1835const context = createChatContext(sessionId, true, request);1836// No initialSessionOptions with branch1837const stream = new MockChatResponseStream();1838const token = disposables.add(new CancellationTokenSource()).token;18391840await participant.createHandler()(request, context, stream, token);18411842expect(mockInitializeFolderRepository).toHaveBeenCalled();1843const [, options] = mockInitializeFolderRepository.mock.calls[0] as unknown as Parameters<typeof folderRepositoryManager.initializeFolderRepository>;1844expect(options.branch).toBeUndefined();1845});1846});18471848describe('chatSessionContext lost workaround (core bug)', () => {1849// Full end-to-end tests for the delegation → executeCommand → workaround round-trip.1850//1851// When delegating from another chat:1852// 1. handleRequest is called with chatSessionContext=undefined → triggers handleDelegationFromAnotherChat1853// 2. createCLISessionAndSubmitRequest creates a session, stores prompt in contextForRequest,1854// then calls vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', ...)1855// 3. VS Code core opens the new session and calls handleRequest again with the copilotcli:// resource,1856// but due to a core bug chatSessionContext may be undefined1857// 4. The workaround detects the copilotcli:// scheme + stored contextForRequest data and1858// reconstructs a synthetic chatSessionContext, so the session is reused with the stored prompt.18591860let callbackDone: Promise<void> | undefined;18611862beforeEach(() => {1863callbackDone = undefined;1864// Override the default round-trip behavior to simulate VS Code core1865// calling handleRequest again with the copilotcli:// resource but with chatSessionContext lost.1866mockExecuteCommand.mockImplementation(async (command: string, args: any) => {1867if (command === 'workbench.action.chat.openSessionWithPrompt.copilotcli') {1868// Simulate VS Code core: it opens the session and fires handleRequest,1869// but the core bug means chatSessionContext is undefined.1870const callbackRequest = new TestChatRequest(args.prompt);1871callbackRequest.sessionResource = args.resource;1872const callbackContext = { chatSessionContext: undefined } as vscode.ChatContext;1873const callbackStream = new MockChatResponseStream();1874const callbackToken = disposables.add(new CancellationTokenSource()).token;1875const result = participant.createHandler()(callbackRequest, callbackContext, callbackStream, callbackToken);1876callbackDone = !result ? Promise.resolve() : Promise.resolve(result).then(() => {/** */ });1877await callbackDone;1878}1879});1880});18811882it('full delegation round-trip: executeCommand triggers callback that uses workaround to reconstruct context and reuse session', async () => {1883// Start delegation: call handleRequest with no chatSessionContext.1884// This triggers handleDelegationFromAnotherChat → createCLISessionAndSubmitRequest1885// which creates a session, stores prompt/attachments, calls executeCommand.1886// The mock executeCommand simulates VS Code calling handleRequest again with1887// the copilotcli:// resource but chatSessionContext=undefined (the core bug).1888// The workaround reconstructs context and reuses the session.1889const request = new TestChatRequest('Build feature X');1890const context = { chatSessionContext: undefined } as vscode.ChatContext;1891const stream = new MockChatResponseStream();1892const token = disposables.add(new CancellationTokenSource()).token;18931894await participant.createHandler()(request, context, stream, token);1895await callbackDone;18961897// executeCommand should have been called with the correct command and args1898expect(mockExecuteCommand).toHaveBeenCalledWith(1899'workbench.action.chat.openSessionWithPrompt.copilotcli',1900expect.objectContaining({1901resource: expect.objectContaining({ scheme: 'copilotcli' }),1902prompt: 'Build feature X',1903})1904);19051906// Only one session should have been created (the delegation creates it,1907// and the callback reuses it via the workaround — no second session).1908expect(cliSessions.length).toBe(1);19091910// The session's handleRequest should have been called exactly once,1911// using the stored prompt from contextForRequest (set during delegation).1912expect(cliSessions[0].requests.length).toBe(1);1913expect(cliSessions[0].requests[0].input).toEqual(1914expect.objectContaining({ prompt: expect.stringContaining('Build feature X') })1915);19161917// contextForRequest should have been consumed (cleaned up after use)1918expect((participant as any).contextForRequest.size).toBe(0);1919});19201921it('does not attempt workaround for non-copilotcli resource and proceeds with normal delegation', async () => {1922const request = new TestChatRequest('do some work');1923// Default sessionResource is test://session/... (not copilotcli scheme),1924// so the workaround check at the top of handleRequest is skipped entirely.1925const context = { chatSessionContext: undefined } as vscode.ChatContext;1926const stream = new MockChatResponseStream();1927const token = disposables.add(new CancellationTokenSource()).token;19281929await participant.createHandler()(request, context, stream, token);1930await callbackDone;19311932// A session should have been created via the delegation path1933expect(cliSessions.length).toBe(1);1934expect(cliSessions[0].requests.length).toBe(1);1935expect(cliSessions[0].requests[0].input).toEqual(1936expect.objectContaining({ prompt: expect.stringContaining('do some work') })1937);1938});1939});19401941describe('agent tool references via modeInstructions2', () => {1942class MockCopilotCLIAgentsWithCustomAgent extends NullCopilotCLIAgents {1943constructor(private readonly agentTools: string[] | null) {1944super();1945}1946override resolveAgent(agentId: string): Promise<SweCustomAgent | undefined> {1947if (agentId === 'custom-agent') {1948return Promise.resolve({1949name: 'custom-agent',1950displayName: 'Custom Agent',1951description: 'A test agent',1952tools: this.agentTools,1953prompt: async () => 'System prompt',1954disableModelInvocation: false,1955});1956}1957return Promise.resolve(undefined);1958}1959}19601961function makeParticipantWithAgents(agents: MockCopilotCLIAgentsWithCustomAgent): CopilotCLIChatSessionParticipant {1962const nullDelegationService = new class extends mock<IChatDelegationSummaryService>() {1963override async summarize(_context: vscode.ChatContext, _token: vscode.CancellationToken): Promise<string | undefined> {1964return undefined;1965}1966}();1967return new CopilotCLIChatSessionParticipant(1968contentProvider,1969promptResolver,1970itemProvider,1971cloudProvider,1972undefined,1973git,1974models as unknown as ICopilotCLIModels,1975agents,1976sessionService,1977worktree,1978worktreeCheckpointService,1979workspaceFolderService,1980telemetry,1981logService,1982disposables.add(new MockPromptsService()),1983nullDelegationService,1984folderRepositoryManager,1985configurationService,1986sdk,1987new MockChatSessionMetadataStore(),1988customSessionTitleService,1989new (mock<IOctoKitService>())(),1990);1991}19921993it('preserves agent tools when modeInstructions2 has no tool references', async () => {1994const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(['original-tool']));1995const createSessionSpy = vi.spyOn(sessionService, 'createSession');19961997const request = new TestChatRequest('Do something');1998(request as any).modeInstructions2 = { name: 'custom-agent', content: 'agent content' };1999const context = createChatContext('temp-new', true, request);2000const stream = new MockChatResponseStream();2001const token = disposables.add(new CancellationTokenSource()).token;20022003await agentParticipant.createHandler()(request, context, stream, token);20042005expect(createSessionSpy).toHaveBeenCalled();2006const { agent } = createSessionSpy.mock.calls[0][0];2007expect(agent?.tools).toEqual(['original-tool']);2008});20092010it('overrides agent tools when modeInstructions2 provides tool references', async () => {2011const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(['original-tool']));2012const createSessionSpy = vi.spyOn(sessionService, 'createSession');20132014const request = new TestChatRequest('Do something');2015(request as any).modeInstructions2 = {2016name: 'custom-agent',2017content: 'agent content',2018toolReferences: [{ name: 'override-tool-1' }, { name: 'override-tool-2' }],2019};2020const context = createChatContext('temp-new', true, request);2021const stream = new MockChatResponseStream();2022const token = disposables.add(new CancellationTokenSource()).token;20232024await agentParticipant.createHandler()(request, context, stream, token);20252026expect(createSessionSpy).toHaveBeenCalled();2027const { agent } = createSessionSpy.mock.calls[0][0];2028expect(agent?.tools).toEqual(['override-tool-1', 'override-tool-2']);2029});20302031it('preserves null tools when modeInstructions2 has no tool references', async () => {2032const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(null));2033const createSessionSpy = vi.spyOn(sessionService, 'createSession');20342035const request = new TestChatRequest('Do something');2036(request as any).modeInstructions2 = { name: 'custom-agent', content: 'agent content' };2037const context = createChatContext('temp-new', true, request);2038const stream = new MockChatResponseStream();2039const token = disposables.add(new CancellationTokenSource()).token;20402041await agentParticipant.createHandler()(request, context, stream, token);20422043expect(createSessionSpy).toHaveBeenCalled();2044const { agent } = createSessionSpy.mock.calls[0][0];2045expect(agent?.tools).toBeNull();2046});20472048it('does not use session agent when no modeInstructions2 is provided', async () => {2049const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(['tool-a']));2050const createSessionSpy = vi.spyOn(sessionService, 'createSession');20512052const request = new TestChatRequest('Do something');2053// No modeInstructions2 set — agent should be undefined regardless of session state2054const context = createChatContext('temp-new', true, request);2055const stream = new MockChatResponseStream();2056const token = disposables.add(new CancellationTokenSource()).token;20572058await agentParticipant.createHandler()(request, context, stream, token);20592060expect(createSessionSpy).toHaveBeenCalled();2061const { agent } = createSessionSpy.mock.calls[0][0];2062expect(agent).toBeUndefined();2063});2064});20652066describe('PR detection with retry', () => {2067let octoKitService: IOctoKitService;20682069const v2WorktreeProperties: ChatSessionWorktreePropertiesV2 = {2070version: 2,2071baseCommit: 'abc123',2072branchName: 'copilot/test-branch',2073baseBranchName: 'main',2074repositoryPath: `${sep}repo`,2075worktreePath: `${sep}worktree`,2076};20772078const repoContext: RepoContext = {2079rootUri: Uri.file(`${sep}repo`),2080kind: 'repository',2081remotes: ['origin'],2082remoteFetchUrls: ['https://github.com/testowner/testrepo.git'],2083} as unknown as RepoContext;20842085beforeEach(() => {2086vi.useFakeTimers();2087octoKitService = {2088findPullRequestByHeadBranch: vi.fn(async () => undefined),2089} as unknown as IOctoKitService;20902091// Set up folder & git repo so session creation succeeds with worktree isolation2092folderRepositoryManager.setNewSessionFolder('untitled:pr-test', Uri.file(`${sep}repo`));2093git.setRepo(repoContext);2094(worktree.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(v2WorktreeProperties);2095// After session creation, getWorktreeProperties returns v2 for any session2096(worktree.getWorktreeProperties as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(v2WorktreeProperties);2097TestCopilotCLISession.statusOverride = vscode.ChatSessionStatus.Completed;20982099// Recreate participant with the controllable octoKitService2100participant = new CopilotCLIChatSessionParticipant(2101contentProvider,2102promptResolver,2103itemProvider,2104cloudProvider,2105undefined,2106git,2107models as unknown as ICopilotCLIModels,2108new NullCopilotCLIAgents(),2109sessionService,2110worktree,2111worktreeCheckpointService,2112workspaceFolderService,2113telemetry,2114logService,2115disposables.add(new MockPromptsService()),2116new (mock<IChatDelegationSummaryService>())(),2117folderRepositoryManager,2118configurationService,2119sdk,2120new MockChatSessionMetadataStore(),2121customSessionTitleService,2122octoKitService,2123);2124});21252126afterEach(() => {2127vi.useRealTimers();2128});21292130it('retries PR detection with exponential backoff and succeeds on second attempt', async () => {2131const findPr = octoKitService.findPullRequestByHeadBranch as ReturnType<typeof vi.fn>;2132findPr2133.mockResolvedValueOnce(undefined) // attempt 1: not found2134.mockResolvedValueOnce({ url: 'https://github.com/testowner/testrepo/pull/42', state: 'OPEN' }); // attempt 2: found21352136const request = new TestChatRequest('Create a PR');2137const context = createChatContext('untitled:pr-test', true, request);2138const stream = new MockChatResponseStream();2139const token = disposables.add(new CancellationTokenSource()).token;21402141const handlerPromise = participant.createHandler()(request, context, stream, token);2142await vi.runAllTimersAsync();2143await handlerPromise;21442145// Should have been called twice (after 2s delay, then after 4s delay)2146expect(findPr).toHaveBeenCalledTimes(2);2147// Should have persisted the PR URL and state2148expect(worktree.setWorktreeProperties).toHaveBeenCalledWith(2149expect.any(String),2150expect.objectContaining({ pullRequestUrl: 'https://github.com/testowner/testrepo/pull/42', pullRequestState: 'open' })2151);2152});21532154it('stops retrying once all attempts are exhausted', async () => {2155const findPr = octoKitService.findPullRequestByHeadBranch as ReturnType<typeof vi.fn>;2156findPr.mockResolvedValue(undefined); // always returns not found21572158const request = new TestChatRequest('Create something');2159const context = createChatContext('untitled:pr-test', true, request);2160const stream = new MockChatResponseStream();2161const token = disposables.add(new CancellationTokenSource()).token;21622163const handlerPromise = participant.createHandler()(request, context, stream, token);2164await vi.runAllTimersAsync();2165await handlerPromise;21662167// 5 attempts total (after 2s, 4s, 8s, 16s, and 32s delays)2168expect(findPr).toHaveBeenCalledTimes(5);2169// Should NOT have persisted any PR URL since all attempts failed2170const setPropsCallsWithPrUrl = (worktree.setWorktreeProperties as ReturnType<typeof vi.fn>).mock.calls2171.filter((args: unknown[]) => (args[1] as { pullRequestUrl?: string })?.pullRequestUrl !== undefined);2172expect(setPropsCallsWithPrUrl).toHaveLength(0);2173});21742175it('skips retry when session already has createdPullRequestUrl', async () => {2176const findPr = octoKitService.findPullRequestByHeadBranch as ReturnType<typeof vi.fn>;21772178// Make the session report a PR URL directly2179TestCopilotCLISession.handleRequestHook = vi.fn(async () => {2180const session = cliSessions[cliSessions.length - 1];2181(session as any)._createdPullRequestUrl = 'https://github.com/testowner/testrepo/pull/99';2182});21832184const request = new TestChatRequest('Create a PR via MCP');2185const context = createChatContext('untitled:pr-test', true, request);2186const stream = new MockChatResponseStream();2187const token = disposables.add(new CancellationTokenSource()).token;21882189const handlerPromise = participant.createHandler()(request, context, stream, token);2190await vi.runAllTimersAsync();2191await handlerPromise;21922193// Should NOT have called the GitHub API since session had the URL2194expect(findPr).not.toHaveBeenCalled();2195// Should have persisted the session's PR URL2196expect(worktree.setWorktreeProperties).toHaveBeenCalledWith(2197expect.any(String),2198expect.objectContaining({ pullRequestUrl: 'https://github.com/testowner/testrepo/pull/99' })2199);2200});2201});22022203describe('sdkToUntitledUriMapping lifecycle', () => {2204it('populates sdkToUntitledUriMapping during request and cleans up after swap', async () => {2205folderRepositoryManager.setNewSessionFolder('untitled:mapping-test', Uri.file(`${sep}workspace`));22062207let capturedSdkSessionId: string | undefined;2208let mappingExistedDuringRequest = false;2209TestCopilotCLISession.handleRequestHook = vi.fn(async () => {2210const session = cliSessions[cliSessions.length - 1];2211capturedSdkSessionId = session.sessionId;2212mappingExistedDuringRequest = itemProvider.sdkToUntitledUriMapping.has(capturedSdkSessionId);2213});22142215const request = new TestChatRequest('Hello');2216const context = createChatContext('untitled:mapping-test', true, request);2217const stream = new MockChatResponseStream();2218const token = disposables.add(new CancellationTokenSource()).token;22192220await participant.createHandler()(request, context, stream, token);22212222// Mapping should have existed during the request2223expect(mappingExistedDuringRequest).toBe(true);2224// After the request completes and the session is swapped, the mapping should be cleaned up2225expect(itemProvider.sdkToUntitledUriMapping.has(capturedSdkSessionId!)).toBe(false);2226});22272228it('maps SDK session ID to the original untitled URI', async () => {2229folderRepositoryManager.setNewSessionFolder('untitled:uri-check', Uri.file(`${sep}workspace`));22302231let capturedUri: Uri | undefined;2232TestCopilotCLISession.handleRequestHook = vi.fn(async () => {2233const session = cliSessions[cliSessions.length - 1];2234capturedUri = itemProvider.sdkToUntitledUriMapping.get(session.sessionId);2235});22362237const request = new TestChatRequest('Hello');2238const context = createChatContext('untitled:uri-check', true, request);2239const stream = new MockChatResponseStream();2240const token = disposables.add(new CancellationTokenSource()).token;22412242await participant.createHandler()(request, context, stream, token);22432244expect(capturedUri).toBeDefined();2245expect(capturedUri!.scheme).toBe('copilotcli');2246expect(capturedUri!.path).toBe('/untitled:uri-check');2247});22482249it('does not populate sdkToUntitledUriMapping for existing sessions', async () => {2250const sessionId = 'existing-mapping-test';2251const sdkSession = new MockCliSdkSession(sessionId, new Date());2252manager.sessions.set(sessionId, sdkSession);22532254const request = new TestChatRequest('Continue');2255const context = createChatContext(sessionId, false, request);2256const stream = new MockChatResponseStream();2257const token = disposables.add(new CancellationTokenSource()).token;22582259await participant.createHandler()(request, context, stream, token);22602261expect(cliSessions.length).toBe(1);2262// Should NOT have set sdkToUntitledUriMapping for existing sessions2263expect(itemProvider.sdkToUntitledUriMapping.size).toBe(0);2264});2265});2266});226722682269