Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { SessionOptions } from '@github/copilot/sdk';6import { mkdir, mkdtemp, rm, writeFile as writeNodeFile } from 'node:fs/promises';7import { tmpdir } from 'node:os';8import { join } from 'node:path';9import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';10import type { ChatContext, ChatParticipantToolToken, Uri } from 'vscode';11import { CancellationToken } from 'vscode-languageserver-protocol';12import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';13import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/common/chatDebugFileLoggerService';14import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';15import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';16import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';17import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';18import { ILogService } from '../../../../../platform/log/common/logService';19import { NullMcpService } from '../../../../../platform/mcp/common/mcpService';20import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index';21import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';22import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger';23import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';24import { mock } from '../../../../../util/common/test/simpleMock';25import { DisposableStore, IReference, toDisposable } from '../../../../../util/vs/base/common/lifecycle';26import { URI } from '../../../../../util/vs/base/common/uri';27import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';28import { NullPromptVariablesService } from '../../../../prompt/node/promptVariablesService';29import { createExtensionUnitTestingServices } from '../../../../test/node/services';30import { IAgentSessionsWorkspace } from '../../../common/agentSessionsWorkspace';31import { IChatSessionWorkspaceFolderService } from '../../../common/chatSessionWorkspaceFolderService';32import { IChatSessionWorktreeService } from '../../../common/chatSessionWorktreeService';33import { MockChatSessionMetadataStore } from '../../../common/test/mockChatSessionMetadataStore';34import { IWorkspaceInfo } from '../../../common/workspaceInfo';35import { FakeToolsService } from '../../common/copilotCLITools';36import { ICustomSessionTitleService } from '../../common/customSessionTitleService';37import { IChatDelegationSummaryService } from '../../common/delegationSummaryService';38import { getCopilotCLISessionDir } from '../cliHelpers';39import { ICopilotCLISDK } from '../copilotCli';40import { CopilotCLISession, ICopilotCLISession } from '../copilotcliSession';41import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionItem } from '../copilotcliSessionService';42import { CopilotCLIMCPHandler } from '../mcpHandler';43import { MissionControlApiClient } from '../missionControlApiClient';44import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers';45import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullCopilotCLIModels, NullICopilotCLIImageSupport } from './testHelpers';4647// Re-export for backward compatibility with other spec files48export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers';4950class MockLocalSession {51static async fromEvents(events: readonly { type: string }[]): Promise<{}> {52const unknownEvent = events.find(event => event.type === 'custom.unknown');53if (unknownEvent) {54throw new Error(`Unknown event type: ${unknownEvent.type}. Failed to deserialize session.`);55}56return {};57}58}5960export class NullAgentSessionsWorkspace implements IAgentSessionsWorkspace {61_serviceBrand: undefined;62readonly isAgentSessionsWorkspace = false;63}6465class NullChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {66override deleteTrackedWorkspaceFolder = vi.fn(async () => { });67override trackSessionWorkspaceFolder = vi.fn(async () => { });68override getSessionWorkspaceFolder = vi.fn(async () => undefined);69override handleRequestCompleted = vi.fn(async () => { });70override getWorkspaceChanges = vi.fn(async () => undefined);71override clearWorkspaceChanges: IChatSessionWorkspaceFolderService['clearWorkspaceChanges'] = vi.fn((_sessionIdOrFolderUri: string | Uri) => []);72}7374class NullChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {75override getWorktreeProperties: IChatSessionWorktreeService['getWorktreeProperties'] = vi.fn(async () => undefined);76}7778class NullCustomSessionTitleService implements ICustomSessionTitleService {79declare _serviceBrand: undefined;80private readonly titles = new Map<string, string>();81async getCustomSessionTitle(sessionId: string): Promise<string | undefined> { return this.titles.get(sessionId); }82async setCustomSessionTitle(sessionId: string, title: string): Promise<void> {83this.titles.set(sessionId, title);84}85async generateSessionTitle(_sessionId: string, _request: { prompt?: string; command?: string }): Promise<string | undefined> { return undefined; }86}8788function workspaceInfoFor(workingDirectory: Uri | undefined): IWorkspaceInfo {89return {90folder: workingDirectory,91repository: undefined,92worktree: undefined,93worktreeProperties: undefined,94};95}9697function sessionOptionsFor(workingDirectory?: Uri) {98return {99workspace: workspaceInfoFor(workingDirectory),100};101}102103describe('CopilotCLISessionService', () => {104const disposables = new DisposableStore();105let logService: ILogService;106let instantiationService: IInstantiationService;107let service: CopilotCLISessionService;108let manager: MockCliSdkSessionManager;109let tempStateHome: string | undefined;110const originalXdgStateHome = process.env.XDG_STATE_HOME;111beforeEach(async () => {112vi.useRealTimers();113const sdk = {114getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),115getRequestId: vi.fn(() => undefined),116} as unknown as ICopilotCLISDK;117118const services = disposables.add(createExtensionUnitTestingServices());119const accessor = services.createTestingAccessor();120logService = accessor.get(ILogService);121const workspaceService = new NullWorkspaceService();122const cliAgents = new NullCopilotCLIAgents();123const authService = {124getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),125} as unknown as IAuthenticationService;126const delegationService = new class extends mock<IChatDelegationSummaryService>() {127override async summarize(context: ChatContext, token: CancellationToken): Promise<string | undefined> {128return undefined;129}130override extractPrompt(): { prompt: string; reference: never } | undefined {131return undefined;132}133}();134class FakeUserQuestionHandler implements IUserQuestionHandler {135_serviceBrand: undefined;136async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise<IQuestionAnswer | undefined> {137return undefined;138}139}140141instantiationService = {142invokeFunction(fn: (accessor: unknown, ...args: any[]) => any, ...args: any[]): any {143return fn(accessor, ...args);144},145createInstance: (ctor: unknown, workspaceInfo: any, agentName: any, sdkSession: any) => {146if (ctor === CopilotCLISessionWorkspaceTracker) {147return new class extends mock<CopilotCLISessionWorkspaceTracker>() {148override async initialize(): Promise<void> { return; }149override shouldShowSession(_sessionId: string): { isOldGlobalSession?: boolean; isWorkspaceSession?: boolean } {150return { isOldGlobalSession: false, isWorkspaceSession: true };151}152}();153}154if (ctor === MissionControlApiClient) {155return {156createSession: vi.fn(),157submitEvents: vi.fn(),158getPendingCommands: vi.fn(async () => []),159deleteSession: vi.fn(async () => { }),160};161}162return disposables.add(new CopilotCLISession(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 MockGitService(), { _serviceBrand: undefined } as any));163}164} as unknown as IInstantiationService;165const configurationService = accessor.get(IConfigurationService);166const nullMcpServer = disposables.add(new NullMcpService());167const titleService = new NullCustomSessionTitleService();168service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));169manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;170});171172afterEach(() => {173if (tempStateHome) {174void rm(tempStateHome, { recursive: true, force: true });175tempStateHome = undefined;176}177process.env.XDG_STATE_HOME = originalXdgStateHome;178vi.useRealTimers();179vi.restoreAllMocks();180disposables.clear();181});182183// --- Tests ----------------------------------------------------------------------------------184185it('falls back to a compatibility auto-mode manager when the SDK export is not constructable', async () => {186const sdk = {187getPackage: vi.fn(async () => ({188internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } },189LocalSession: MockLocalSession,190createLocalFeatureFlagService: () => ({}),191AutoModeSessionManager: {} as never,192acquireAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode acquire'); }),193refreshAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode refresh'); }),194AutoModeUnavailableError: class extends Error { },195AutoModeUnsupportedError: class extends Error { },196isAutoModel: (model: string | undefined) => model === 'auto',197noopTelemetryBinder: {},198})),199getRequestId: vi.fn(() => undefined),200} as unknown as ICopilotCLISDK;201202const services = disposables.add(createExtensionUnitTestingServices());203const accessor = services.createTestingAccessor();204const configurationService = accessor.get(IConfigurationService);205const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;206const nullMcpServer = disposables.add(new NullMcpService());207const delegationService = new class extends mock<IChatDelegationSummaryService>() {208override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }209override async summarize(): Promise<string | undefined> { return undefined; }210}();211const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));212213const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager & { opts: { autoModeManager: Record<string, unknown> } };214215expect(localManager.opts.autoModeManager).toEqual(expect.objectContaining({216resolve: expect.any(Function),217clear: expect.any(Function),218handleModelChange: expect.any(Function),219subscribe: expect.any(Function),220}));221});222223describe('CopilotCLISessionService.createSession', () => {224it('get session will return the same session created using createSession', async () => {225const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);226227const existingSession = await service.getSession({ sessionId: session.object.sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);228229expect(existingSession).toBe(session);230});231it('get session will return new once previous session is disposed', async () => {232const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);233234session.dispose();235await new Promise(resolve => setTimeout(resolve, 0)); // allow dispose async cleanup to run236const existingSession = await service.getSession({ sessionId: session.object.sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);237238expect(existingSession?.object).toBeDefined();239expect(existingSession?.object).not.toBe(session);240expect(existingSession?.object.sessionId).toBe(session.object.sessionId);241});242243it('passes clientName: vscode to session manager', async () => {244const createSessionSpy = vi.spyOn(manager, 'createSession');245await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);246247expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({248clientName: 'vscode'249}));250});251252it('passes reasoningEffort to session manager when provided', async () => {253const createSessionSpy = vi.spyOn(manager, 'createSession');254await service.createSession({ model: 'gpt-test', reasoningEffort: 'high', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);255256expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({257model: 'gpt-test',258}));259});260261it('does not set reasoningEffort when not provided', async () => {262const createSessionSpy = vi.spyOn(manager, 'createSession');263await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);264265expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({266model: 'gpt-test',267}));268const callArgs = createSessionSpy.mock.calls[0][0];269expect(callArgs.reasoningEffort).toBeUndefined();270});271});272273describe('CopilotCLISessionService.getSession', () => {274it('passes reasoningEffort to session manager when creating a new session', async () => {275const targetId = 'reasoning-get';276manager.sessions.set(targetId, new MockCliSdkSession(targetId, new Date()));277const getSessionSpy = vi.spyOn(manager, 'getSession');278await service.getSession({ sessionId: targetId, model: 'gpt-test', reasoningEffort: 'medium', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);279280expect(getSessionSpy).toHaveBeenCalledWith(expect.objectContaining({281model: 'gpt-test',282}), true);283});284285it('does not set reasoningEffort when not provided', async () => {286const targetId = 'no-reasoning-get';287manager.sessions.set(targetId, new MockCliSdkSession(targetId, new Date()));288const getSessionSpy = vi.spyOn(manager, 'getSession');289await service.getSession({ sessionId: targetId, model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);290291expect(getSessionSpy).toHaveBeenCalled();292const callArgs = getSessionSpy.mock.calls[0][0];293expect(callArgs.reasoningEffort).toBeUndefined();294});295});296297describe('CopilotCLISessionService.getSession concurrency & locking', () => {298it('concurrent getSession calls for same id create only one wrapper', async () => {299const targetId = 'concurrent';300const sdkSession = new MockCliSdkSession(targetId, new Date());301manager.sessions.set(targetId, sdkSession);302const originalGetSession = manager.getSession.bind(manager);303const getSessionSpy = vi.fn((opts: SessionOptions & { sessionId: string }, writable: boolean) => {304// Introduce delay to force overlapping acquire attempts305return new Promise(resolve => setTimeout(() => resolve(originalGetSession(opts, writable)), 20));306});307manager.getSession = getSessionSpy as unknown as typeof manager.getSession;308309const promises: Promise<IReference<ICopilotCLISession> | undefined>[] = [];310for (let i = 0; i < 10; i++) {311promises.push(service.getSession({ sessionId: targetId, ...sessionOptionsFor() }, CancellationToken.None));312}313const results = await Promise.all(promises);314// All results refer to same instance315const first = results.shift()!;316for (const r of results) {317expect(r).toBe(first);318}319expect(getSessionSpy).toHaveBeenCalledTimes(1);320321// Verify ref-count like disposal only disposes when all callers release322let sentinelDisposed = false;323(first.object as CopilotCLISession).add(toDisposable(() => { sentinelDisposed = true; }));324325results.forEach(r => r?.dispose());326expect(sentinelDisposed).toBe(false);327328// Only after disposing the last reference is the session disposed.329first.dispose();330expect(sentinelDisposed).toBe(true);331});332333it('getSession for different ids does not block on mutex for another id', async () => {334const slowId = 'slow';335const fastId = 'fast';336manager.sessions.set(slowId, new MockCliSdkSession(slowId, new Date()));337manager.sessions.set(fastId, new MockCliSdkSession(fastId, new Date()));338339const originalGetSession = manager.getSession.bind(manager);340manager.getSession = vi.fn((opts: SessionOptions & { sessionId: string }, writable: boolean) => {341if (opts.sessionId === slowId) {342return new Promise(resolve => setTimeout(() => resolve(originalGetSession(opts, writable)), 40));343}344return originalGetSession(opts, writable);345}) as unknown as typeof manager.getSession;346347const slowPromise = service.getSession({ sessionId: slowId, ...sessionOptionsFor() }, CancellationToken.None).then(() => 'slow');348const fastPromise = service.getSession({ sessionId: fastId, ...sessionOptionsFor() }, CancellationToken.None).then(() => 'fast');349const firstResolved = await Promise.race([slowPromise, fastPromise]);350expect(firstResolved).toBe('fast');351});352353it('session only fully disposes after all acquired references dispose', async () => {354const id = 'refcount';355manager.sessions.set(id, new MockCliSdkSession(id, new Date()));356// Acquire 5 times sequentially357const sessions: IReference<ICopilotCLISession>[] = [];358for (let i = 0; i < 5; i++) {359sessions.push((await service.getSession({ sessionId: id, ...sessionOptionsFor() }, CancellationToken.None))!);360}361const base = sessions[0];362for (const s of sessions) {363expect(s).toBe(base);364}365let sentinelDisposed = false;366const lastSession = sessions.pop()!;367(lastSession.object as CopilotCLISession).add(toDisposable(() => { sentinelDisposed = true; }));368// Dispose all other session refs, session should not yet be disposed369sessions.forEach(s => s.dispose());370expect(sentinelDisposed).toBe(false);371// Final dispose triggers actual disposal372lastSession.dispose();373expect(sentinelDisposed).toBe(true);374});375});376377describe('CopilotCLISessionService.getSession missing', () => {378it('returns undefined when underlying manager has no session', async () => {379const session = await service.getSession({ sessionId: 'does-not-exist', ...sessionOptionsFor() }, CancellationToken.None);380disposables.add(session!);381expect(session).toBeUndefined();382});383});384385describe('CopilotCLISessionService.renameSession', () => {386it('renames an inactive session through copilot/sdk', async () => {387const sessionId = 'rename-inactive';388manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date()));389390await service.renameSession(sessionId, 'Renamed From VS Code');391392expect(manager.sessions.get(sessionId)?.title).toBe('Renamed From VS Code');393expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Renamed From VS Code');394});395396it('renames an active wrapped session through copilot/sdk', async () => {397const session = await service.createSession({ sessionId: 'rename-active', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);398399await service.renameSession(session.object.sessionId, 'Wrapped Session Name');400401expect(manager.sessions.get(session.object.sessionId)?.title).toBe('Wrapped Session Name');402expect(await service.getSessionTitle(session.object.sessionId, CancellationToken.None)).toBe('Wrapped Session Name');403session.dispose();404});405406it('updates session summaries through copilot/sdk for untitled sessions', async () => {407const sessionId = 'summary-session';408manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date()));409410await service.updateSessionSummary(sessionId, 'Generated Summary');411412expect(manager.sessions.get(sessionId)?.summary).toBe('Generated Summary');413expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Generated Summary');414});415416it('syncs staged titles for newly created vscode sessions into copilot/sdk', async () => {417const sessionId = service.createNewSessionId();418await (service as unknown as { customSessionTitleService: ICustomSessionTitleService }).customSessionTitleService.setCustomSessionTitle(sessionId, 'Staged Session Title');419420const session = await service.createSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);421422expect(manager.sessions.get(sessionId)?.summary).toBe('Staged Session Title');423expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Staged Session Title');424session.dispose();425});426427it('keeps an established session list title stable while a new request is pending', async () => {428const sessionId = 'stable-while-pending';429const sdkSession = new MockCliSdkSession(sessionId, new Date());430sdkSession.summary = 'Original Session Title';431manager.sessions.set(sessionId, sdkSession);432433const session = await service.getSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);434(session!.object as unknown as { _pendingPrompt: string | undefined })._pendingPrompt = 'Latest in-flight request';435436const sessions = await service.getAllSessions(CancellationToken.None);437expect(sessions.find(item => item.id === sessionId)?.label).toBe('Original Session Title');438439session!.dispose();440});441});442443describe('CopilotCLISessionService.tryGetPartialSesionHistory', () => {444it('reconstructs history from persisted files', async () => {445tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));446process.env.XDG_STATE_HOME = tempStateHome;447const sessionId = 'partial-session';448const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));449const fileSystem = new MockFileSystemService();450const sdk = {451getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))452} as unknown as ICopilotCLISDK;453const services = createExtensionUnitTestingServices();454disposables.add(services);455const accessor = services.createTestingAccessor();456const configurationService = accessor.get(IConfigurationService);457const authService = {458getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),459} as unknown as IAuthenticationService;460const nullMcpServer = disposables.add(new NullMcpService());461const titleService = new NullCustomSessionTitleService();462const delegationService = new class extends mock<IChatDelegationSummaryService>() {463override extractPrompt(): { prompt: string; reference: never } | undefined {464return undefined;465}466}();467const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));468469await mkdir(sessionDir.fsPath, { recursive: true });470await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [471JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath, gitRoot: URI.file('/workspace/repo').fsPath, repository: URI.file('/workspace/repo').fsPath } } }),472JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'Repair the session', attachments: [] } }),473JSON.stringify({ id: '3', type: 'assistant.message', timestamp: '2024-01-01T00:00:03.000Z', parentId: '2', data: { content: 'Recovered history' } }),474].join('\n'));475476const partialHistory = await partialService.tryGetPartialSessionHistory(sessionId);477478expect(partialHistory).toBeDefined();479expect(partialHistory).toHaveLength(2);480expect(partialService.getSessionWorkingDirectory(sessionId)?.fsPath).toBe(URI.file('/workspace/project').fsPath);481});482483it('returns cached result on second call without re-reading the file', async () => {484tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));485process.env.XDG_STATE_HOME = tempStateHome;486const sessionId = 'cache-test-session';487const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));488const fileSystem = new MockFileSystemService();489const sdk = {490getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))491} as unknown as ICopilotCLISDK;492const services = createExtensionUnitTestingServices();493disposables.add(services);494const accessor = services.createTestingAccessor();495const configurationService = accessor.get(IConfigurationService);496const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;497const nullMcpServer = disposables.add(new NullMcpService());498const titleService = new NullCustomSessionTitleService();499const delegationService = new class extends mock<IChatDelegationSummaryService>() {500override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }501}();502const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));503504await mkdir(sessionDir.fsPath, { recursive: true });505const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl');506await writeNodeFile(eventsFilePath, [507JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),508JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'First call fills cache', attachments: [] } }),509].join('\n'));510511const history1 = await partialService.tryGetPartialSessionHistory(sessionId);512513// Remove the file so a second disk read would fail514await rm(eventsFilePath);515516// Second call must return the cached array (same reference, no re-read)517const history2 = await partialService.tryGetPartialSessionHistory(sessionId);518519expect(history2).toBe(history1);520});521522it('returns undefined when the events file does not exist', async () => {523tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));524process.env.XDG_STATE_HOME = tempStateHome;525526const result = await service.tryGetPartialSessionHistory('nonexistent-session-id');527expect(result).toBeUndefined();528});529});530531describe('CopilotCLISessionService.getAllSessions', () => {532it('will not list created sessions', async () => {533const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);534disposables.add(session);535536const s1 = new MockCliSdkSession('s1', new Date(0));537s1.messages.push({ role: 'user', content: 'a'.repeat(100) });538s1.events.push({ type: 'user.message', data: { content: 'a'.repeat(100) }, timestamp: '2024-01-01T00:00:00.000Z' });539manager.sessions.set(s1.sessionId, s1);540541const result = await service.getAllSessions(CancellationToken.None);542543expect(result.length).toBe(1);544const item = result[0];545expect(item.id).toBe('s1');546});547548it('falls back to partial session data when getSession fails with an unknown event type', async () => {549tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));550process.env.XDG_STATE_HOME = tempStateHome;551const sessionId = 'invalid-session';552const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));553const fileSystem = new MockFileSystemService();554const sdk = {555getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))556} as unknown as ICopilotCLISDK;557const services = createExtensionUnitTestingServices();558disposables.add(services);559const accessor = services.createTestingAccessor();560const configurationService = accessor.get(IConfigurationService);561const authService = {562getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),563} as unknown as IAuthenticationService;564const nullMcpServer = disposables.add(new NullMcpService());565const titleService = new NullCustomSessionTitleService();566const delegationService = new class extends mock<IChatDelegationSummaryService>() {567override extractPrompt(): { prompt: string; reference: never } | undefined {568return undefined;569}570}();571const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));572const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;573574const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));575session.summary = 'Broken summary <current_dateti...';576partialManager.sessions.set(sessionId, session);577partialManager.getSession = vi.fn(async () => {578throw new Error('Failed to load session. Unknown event type: custom.unknown.');579}) as unknown as typeof partialManager.getSession;580581await mkdir(sessionDir.fsPath, { recursive: true });582await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [583JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),584JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'Use fallback history', attachments: [] } }),585].join('\n'));586587const sessions = await partialService.getAllSessions(CancellationToken.None);588589expect(sessions).toHaveLength(1);590expect(sessions[0].id).toBe(sessionId);591expect(sessions[0].label).toBe('Use fallback history');592});593594it('does not emit session when summary is truncated and no user turns exist', async () => {595tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));596process.env.XDG_STATE_HOME = tempStateHome;597const sessionId = 'no-user-turns-session';598const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));599const fileSystem = new MockFileSystemService();600const sdk = {601getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))602} as unknown as ICopilotCLISDK;603const services = createExtensionUnitTestingServices();604disposables.add(services);605const accessor = services.createTestingAccessor();606const configurationService = accessor.get(IConfigurationService);607const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;608const nullMcpServer = disposables.add(new NullMcpService());609const titleService = new NullCustomSessionTitleService();610const delegationService = new class extends mock<IChatDelegationSummaryService>() {611override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }612}();613const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));614const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;615616// Session has a summary with '<' (which forces the session-load fallback path)617// but no readable user turns in the events file.618const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));619session.summary = 'Summary without user turns <current_dateti...';620partialManager.sessions.set(sessionId, session);621partialManager.getSession = vi.fn(async () => {622throw new Error('Failed to load session. Unknown event type: custom.unknown.');623}) as unknown as typeof partialManager.getSession;624625await mkdir(sessionDir.fsPath, { recursive: true });626// events.jsonl only contains session.start — no user.message events627await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [628JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),629].join('\n'));630631const sessions = await partialService.getAllSessions(CancellationToken.None);632633// Session still appears, using the metadata summary as a best-effort label634expect(sessions).toHaveLength(1);635expect(sessions[0].id).toBe(sessionId);636expect(sessions[0].label).toBe('Summary without user turns <current_dateti...');637});638});639640describe('CopilotCLISessionService.deleteSession', () => {641it('disposes active wrapper, removes from manager and fires change event', async () => {642const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);643const id = session!.object.sessionId;644let fired = false;645disposables.add(session);646disposables.add(service.onDidChangeSessions(() => { fired = true; }));647await service.deleteSession(id);648649expect(manager.sessions.has(id)).toBe(false);650expect(fired).toBe(true);651652expect(await service.getSession({ sessionId: id, ...sessionOptionsFor() }, CancellationToken.None)).toBeUndefined();653});654655it('fires onDidDeleteSession with the session id', async () => {656const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);657const id = session!.object.sessionId;658const deletedIds: string[] = [];659disposables.add(session);660disposables.add(service.onDidDeleteSession(deletedId => deletedIds.push(deletedId)));661await service.deleteSession(id);662663expect(deletedIds).toHaveLength(1);664expect(deletedIds[0]).toBe(id);665});666667it('clears partial session history cache and working directory on delete', async () => {668const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);669const id = session.object.sessionId;670disposables.add(session);671672// Manually populate both caches to simulate a prior tryGetPartialSesionHistory call673const partialHistories = (service as any)._partialSessionHistories as Map<string, readonly unknown[]>;674const workingDirs = (service as any)._sessionWorkingDirectories as Map<string, Uri | undefined>;675partialHistories.set(id, []);676workingDirs.set(id, URI.file('/some/working/dir'));677678expect(partialHistories.has(id)).toBe(true);679expect(workingDirs.has(id)).toBe(true);680681await service.deleteSession(id);682683expect(partialHistories.has(id)).toBe(false);684expect(workingDirs.has(id)).toBe(false);685});686});687688describe('CopilotCLISessionService.getSession cache clearing', () => {689it('clears partial session history when reusing an existing active session', async () => {690const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);691const id = session.object.sessionId;692693// Simulate a partial history entry that was populated before the session was loaded694const partialHistories = (service as any)._partialSessionHistories as Map<string, readonly unknown[]>;695partialHistories.set(id, []);696expect(partialHistories.has(id)).toBe(true);697698// getSession with the same id reuses the existing wrapper and should clear the partial cache699const reused = await service.getSession({ sessionId: id, ...sessionOptionsFor() }, CancellationToken.None);700701expect(reused).toBe(session);702expect(partialHistories.has(id)).toBe(false);703704session.dispose();705reused?.dispose();706});707});708709describe('CopilotCLISessionService.label generation', () => {710it('uses first user message line when present', async () => {711const s = new MockCliSdkSession('lab1', new Date());712s.messages.push({ role: 'user', content: 'Line1\nLine2' });713s.events.push({ type: 'user.message', data: { content: 'Line1\nLine2' }, timestamp: Date.now().toString() });714manager.sessions.set(s.sessionId, s);715716const sessions = await service.getAllSessions(CancellationToken.None);717const item = sessions.find(i => i.id === 'lab1');718expect(item?.label).includes('Line1');719expect(item?.label).includes('Line2');720});721722it('uses clean summary from metadata without loading the full session', async () => {723const s = new MockCliSdkSession('summary1', new Date());724s.summary = 'Fix the login bug';725s.events.push({ type: 'user.message', data: { content: 'Fix the login bug in auth.ts' }, timestamp: Date.now().toString() });726manager.sessions.set(s.sessionId, s);727728const getSessionSpy = vi.spyOn(manager, 'getSession');729const sessions = await service.getAllSessions(CancellationToken.None);730731const item = sessions.find(i => i.id === 'summary1');732expect(item?.label).toBe('Fix the login bug');733// Should not have loaded the full session since summary was clean734expect(getSessionSpy).not.toHaveBeenCalled();735});736737it('falls through to session load when summary contains angle bracket', async () => {738const s = new MockCliSdkSession('truncated1', new Date());739s.summary = 'Fix the bug... <current_dateti...';740s.events.push({ type: 'user.message', data: { content: 'Fix the bug in the parser' }, timestamp: Date.now().toString() });741manager.sessions.set(s.sessionId, s);742743const getSessionSpy = vi.spyOn(manager, 'getSession');744const sessions = await service.getAllSessions(CancellationToken.None);745746const item = sessions.find(i => i.id === 'truncated1');747expect(item?.label).toBe('Fix the bug in the parser');748// Should have loaded the full session because summary had '<'749expect(getSessionSpy).toHaveBeenCalled();750});751752it('uses cached label on second call without loading session again', async () => {753const s = new MockCliSdkSession('cache1', new Date());754// No summary forces session load on first call755s.events.push({ type: 'user.message', data: { content: 'Refactor the tests' }, timestamp: Date.now().toString() });756manager.sessions.set(s.sessionId, s);757758// First call - loads session and caches the label759const sessions1 = await service.getAllSessions(CancellationToken.None);760const item1 = sessions1.find(i => i.id === 'cache1');761expect(item1?.label).toBe('Refactor the tests');762763// Now spy on getSession for the second call764const getSessionSpy = vi.spyOn(manager, 'getSession');765766// Second call - should use cached label767const sessions2 = await service.getAllSessions(CancellationToken.None);768const item2 = sessions2.find(i => i.id === 'cache1');769expect(item2?.label).toBe('Refactor the tests');770// Should not have loaded the full session on second call771expect(getSessionSpy).not.toHaveBeenCalled();772});773774it('uses metadata summary over stale internal label cache', async () => {775const s = new MockCliSdkSession('priority1', new Date());776// No summary initially - forces session load and caching777s.events.push({ type: 'user.message', data: { content: 'Original label from events' }, timestamp: Date.now().toString() });778manager.sessions.set(s.sessionId, s);779780// First call caches label from events781const sessions1 = await service.getAllSessions(CancellationToken.None);782expect(sessions1.find(i => i.id === 'priority1')?.label).toBe('Original label from events');783784// Now add a summary to the metadata - the cached label should still be used785s.summary = 'Different summary label';786787const sessions2 = await service.getAllSessions(CancellationToken.None);788expect(sessions2.find(i => i.id === 'priority1')?.label).toBe('Original label from events');789});790791it('populates cache after loading session for label', async () => {792const s = new MockCliSdkSession('populate1', new Date());793s.events.push({ type: 'user.message', data: { content: 'Add unit tests for auth' }, timestamp: Date.now().toString() });794manager.sessions.set(s.sessionId, s);795796await service.getAllSessions(CancellationToken.None);797798// Verify the internal cache was populated799const labelCache = (service as any)._sessionLabels as Map<string, string>;800expect(labelCache.get('populate1')).toBe('Add unit tests for auth');801});802803it('does not cache when using clean summary from metadata directly', async () => {804const s = new MockCliSdkSession('nocache1', new Date());805s.summary = 'Clean summary without brackets';806manager.sessions.set(s.sessionId, s);807808await service.getAllSessions(CancellationToken.None);809810// The cache should not have an entry since the summary was used directly811const labelCache = (service as any)._sessionLabels as Map<string, string>;812expect(labelCache.has('nocache1')).toBe(false);813});814});815816describe('CopilotCLISessionService.createNewSessionId / isNewSessionId', () => {817it('createNewSessionId returns a unique id that isNewSessionId recognises', () => {818const id = service.createNewSessionId();819expect(id).toBeTruthy();820expect(service.isNewSessionId(id)).toBe(true);821});822823it('isNewSessionId returns false for an unknown id', () => {824expect(service.isNewSessionId('not-a-new-id')).toBe(false);825});826827it('successive calls return distinct ids', () => {828const a = service.createNewSessionId();829const b = service.createNewSessionId();830expect(a).not.toBe(b);831expect(service.isNewSessionId(a)).toBe(true);832expect(service.isNewSessionId(b)).toBe(true);833});834835it('createSession clears the new-session flag', async () => {836const id = service.createNewSessionId();837expect(service.isNewSessionId(id)).toBe(true);838839await service.createSession({ model: 'gpt-test', sessionId: id, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);840841expect(service.isNewSessionId(id)).toBe(false);842});843});844845describe('CopilotCLISessionService.forkSession', () => {846it('delegates to sessionManager.forkSession and returns the new session id', async () => {847const sourceId = 'source-session';848manager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));849const forkSpy = vi.spyOn(manager, 'forkSession');850851const newId = await service.forkSession({ sessionId: sourceId, requestId: undefined, workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);852853expect(forkSpy).toHaveBeenCalledWith(sourceId, undefined);854expect(newId).toBeTruthy();855expect(newId).not.toBe(sourceId);856});857858it('stores forked session metadata via storeForkedSessionMetadata', async () => {859const sourceId = 'meta-source';860manager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));861const metadataStore = new MockChatSessionMetadataStore();862const storeMetadataSpy = vi.spyOn(metadataStore, 'storeForkedSessionMetadata');863864const sdk = {865getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),866getRequestId: vi.fn(() => undefined),867} as unknown as ICopilotCLISDK;868const services = disposables.add(createExtensionUnitTestingServices());869const accessor = services.createTestingAccessor();870const configurationService = accessor.get(IConfigurationService);871const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;872const nullMcpServer = disposables.add(new NullMcpService());873const delegationService = new class extends mock<IChatDelegationSummaryService>() {874override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }875}();876const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));877const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager;878localManager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));879880const newId = await localService.forkSession({ sessionId: sourceId, requestId: undefined, workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);881882expect(storeMetadataSpy).toHaveBeenCalledWith(sourceId, newId, expect.stringContaining('Forked:'));883});884885it('fires onDidCreateSession with the forked session id and title', async () => {886const sourceId = 'event-source';887manager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));888889const created: ICopilotCLISessionItem[] = [];890disposables.add(service.onDidCreateSession(item => created.push(item)));891892const newId = await service.forkSession({ sessionId: sourceId, requestId: undefined, workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);893894expect(created).toHaveLength(1);895expect(created[0].id).toBe(newId);896expect(created[0].label).toContain('Forked:');897});898899it('passes toEventId to sessionManager.forkSession when requestId matches a stored copilot request id', async () => {900const sourceId = 'truncate-source';901const sdkSession = new MockCliSdkSession(sourceId, new Date());902sdkSession.events.push({ type: 'user.message', id: 'sdk-event-1', data: { content: 'hello' }, timestamp: '2024-01-01T00:00:00.000Z' });903manager.sessions.set(sourceId, sdkSession);904905const sdk = {906getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),907getRequestId: vi.fn(() => ({ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1' })),908} as unknown as ICopilotCLISDK;909const services = disposables.add(createExtensionUnitTestingServices());910const accessor = services.createTestingAccessor();911const configurationService = accessor.get(IConfigurationService);912const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;913const nullMcpServer = disposables.add(new NullMcpService());914const delegationService = new class extends mock<IChatDelegationSummaryService>() {915override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }916override async summarize(): Promise<string | undefined> { return undefined; }917}();918const metadataStore = new MockChatSessionMetadataStore();919await metadataStore.updateRequestDetails(sourceId, [{ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1', toolIdEditMap: {} }]);920const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));921const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager;922localManager.sessions.set(sourceId, sdkSession);923const forkSpy = vi.spyOn(localManager, 'forkSession');924925await localService.forkSession({ sessionId: sourceId, requestId: 'vsc-req-1', workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);926927expect(forkSpy).toHaveBeenCalledWith(sourceId, 'sdk-event-1');928});929});930931describe('CopilotCLISessionService.auto disposal timeout', () => {932it.skip('disposes session after completion timeout and aborts underlying sdk session', async () => {933vi.useFakeTimers();934const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);935936vi.advanceTimersByTime(31000);937await Promise.resolve(); // allow any pending promises to run938939// dispose should have been called by timeout940expect(session.object.isDisposed).toBe(true);941});942});943});944945946