Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.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 { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';6import * as path from 'path';7import type * as vscode from 'vscode';8// eslint-disable-next-line no-duplicate-imports9import * as vscodeShim from 'vscode';10import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';11import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';12import { Change, Repository } from '../../../../platform/git/vscode/git';13import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';14import { ITestingServicesAccessor } from '../../../../platform/test/node/services';15import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';16import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';17import { mock } from '../../../../util/common/test/simpleMock';18import { CancellationToken } from '../../../../util/vs/base/common/cancellation';19import { Emitter, Event } from '../../../../util/vs/base/common/event';20import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';21import { observableValue } from '../../../../util/vs/base/common/observableInternal/observables/observableValue';22import { URI } from '../../../../util/vs/base/common/uri';23import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';24import { ChatSessionStatus, MarkdownString, ThemeIcon } from '../../../../vscodeTypes';25import { createExtensionUnitTestingServices } from '../../../test/node/services';26import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';27import { ClaudeFolderInfo } from '../../claude/common/claudeFolderInfo';28import { ClaudeSessionUri } from '../../claude/common/claudeSessionUri';29import type { ClaudeAgentManager } from '../../claude/node/claudeCodeAgent';30import { IClaudeCodeSdkService } from '../../claude/node/claudeCodeSdkService';31import { parseClaudeModelId } from '../../claude/node/claudeModelId';32import { IClaudeSessionStateService } from '../../claude/common/claudeSessionStateService';33import { IClaudeCodeSessionService } from '../../claude/node/sessionParser/claudeCodeSessionService';34import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSessionSchema';35import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService';36import { FolderRepositoryMRUEntry, IChatFolderMruService } from '../../common/folderRepositoryManager';37import { IClaudeWorkspaceFolderService } from '../../common/claudeWorkspaceFolderService';38import { builtinSlashCommands } from '../../common/builtinSlashCommands';39import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider';4041// Expose the most recently created items map so tests can inspect controller items.42let lastCreatedItemsMap: Map<string, vscode.ChatSessionItem>;43// Expose the most recently registered fork handler so tests can invoke it directly.44let lastForkHandler: ((sessionResource: vscode.Uri, request: vscode.ChatRequestTurn2 | undefined, token: CancellationToken) => Thenable<vscode.ChatSessionItem>) | undefined;45// Expose the most recently registered getChatSessionInputState handler so tests can invoke it.46let lastGetChatSessionInputState: vscode.ChatSessionControllerGetInputState | undefined;4748// Patch vscode shim with missing namespaces before any production code imports it.49beforeAll(() => {50(vscodeShim as Record<string, unknown>).commands = {51registerCommand: vi.fn().mockReturnValue({ dispose: () => { } }),52executeCommand: vi.fn().mockResolvedValue(undefined),53};54(vscodeShim as Record<string, unknown>).chat = {55createChatSessionItemController: () => {56const itemsMap = new Map<string, vscode.ChatSessionItem>();57lastCreatedItemsMap = itemsMap;58lastForkHandler = undefined;59lastGetChatSessionInputState = undefined;60return {61id: 'claude-code',62items: {63get: (resource: URI) => itemsMap.get(resource.toString()),64add: (item: vscode.ChatSessionItem) => { itemsMap.set(item.resource.toString(), item); },65delete: (resource: URI) => { itemsMap.delete(resource.toString()); },66replace: (items: vscode.ChatSessionItem[]) => {67itemsMap.clear();68for (const item of items) {69itemsMap.set(item.resource.toString(), item);70}71},72get size() { return itemsMap.size; },73[Symbol.iterator]: function* () { yield* itemsMap.values(); },74forEach: (cb: (item: vscode.ChatSessionItem) => void) => { itemsMap.forEach(cb); },75},76createChatSessionItem: (resource: unknown, label: string) => ({77resource,78label,79}),80createChatSessionInputState: (groups: vscode.ChatSessionProviderOptionGroup[]) => {81const emitter = new Emitter<void>();82const state: vscode.ChatSessionInputState = {83groups,84sessionResource: undefined,85onDidChange: emitter.event,86onDidDispose: Event.None,87};88// Proxy that fires onDidChange when groups are replaced89return new Proxy(state, {90set(target, prop, value) {91(target as any)[prop] = value;92if (prop === 'groups') {93emitter.fire();94}95return true;96},97});98},99set getChatSessionInputState(handler: vscode.ChatSessionControllerGetInputState) { lastGetChatSessionInputState = handler; },100set forkHandler(handler: typeof lastForkHandler) { lastForkHandler = handler; },101refreshHandler: () => Promise.resolve(),102dispose: () => { },103onDidArchiveChatSessionItem: () => ({ dispose: () => { } }),104};105},106};107});108109class MockChatFolderMruService implements IChatFolderMruService {110declare _serviceBrand: undefined;111112private _mruEntries: FolderRepositoryMRUEntry[] = [];113114setMRUEntries(entries: FolderRepositoryMRUEntry[]): void {115this._mruEntries = entries;116}117118async getRecentlyUsedFolders(): Promise<FolderRepositoryMRUEntry[]> {119return this._mruEntries;120}121122async deleteRecentlyUsedFolder(): Promise<void> { }123}124125function createDefaultMocks() {126const mockSessionService: IClaudeCodeSessionService = {127getSession: vi.fn()128} as any;129130const mockFolderMruService = new MockChatFolderMruService();131132return { mockSessionService, mockFolderMruService };133}134135function createMockAgentManager(): ClaudeAgentManager {136return {137handleRequest: vi.fn().mockResolvedValue({}),138} as unknown as ClaudeAgentManager;139}140141/** Creates a TestChatRequest with a mock model that has an id property */142function createTestRequest(prompt: string): TestChatRequest {143const request = new TestChatRequest(prompt);144(request as any).model = { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', family: 'claude' };145return request;146}147148/**149* Adds a session item to the controller's items map.150* This simulates what newChatSessionItemHandler does when VS Code creates a new session.151*/152function seedSessionItem(sessionId: string): void {153const resource = ClaudeSessionUri.forSessionId(sessionId);154const item: vscode.ChatSessionItem = {155resource,156label: sessionId,157};158lastCreatedItemsMap.set(resource.toString(), item);159}160161/**162* Builds a minimal permission mode input state group with the given mode selected.163* Defaults to 'acceptEdits' if no mode specified.164*/165function buildPermissionModeGroup(selectedMode: string = 'acceptEdits'): vscode.ChatSessionProviderOptionGroup {166const items = [167{ id: 'default', name: 'Ask before edits' },168{ id: 'acceptEdits', name: 'Edit automatically' },169{ id: 'plan', name: 'Plan mode' },170{ id: 'bypassPermissions', name: 'Yolo mode' },171{ id: 'dontAsk', name: 'Don\'t ask' },172];173const selected = items.find(i => i.id === selectedMode) ?? items[1];174return {175id: 'permissionMode',176name: 'Permission Mode',177items,178selected: { ...selected },179};180}181182/**183* Builds a minimal folder input state group with the given folder selected.184*/185function buildFolderGroup(selectedFolderPath: string, allFolderPaths?: string[]): vscode.ChatSessionProviderOptionGroup {186const paths = allFolderPaths ?? [selectedFolderPath];187const items = paths.map(p => ({ id: p, name: path.basename(p) }));188const selected = items.find(i => i.id === selectedFolderPath) ?? items[0];189return {190id: 'folder',191name: 'Folder',192items,193selected: { ...selected },194};195}196197/**198* Builds inputState groups for test chat contexts.199* Always includes a permission mode group. Folder group is added when folderPath is provided.200*/201function buildInputStateGroups(options?: { permissionMode?: string; folderPath?: string; allFolderPaths?: string[] }): vscode.ChatSessionProviderOptionGroup[] {202const groups: vscode.ChatSessionProviderOptionGroup[] = [203buildPermissionModeGroup(options?.permissionMode),204];205if (options?.folderPath) {206groups.push(buildFolderGroup(options.folderPath, options.allFolderPaths));207}208return groups;209}210211/**212* Workspace service whose folder list can be mutated at runtime so tests can213* exercise folder-change events through the observable pipeline.214*/215class MutableWorkspaceService extends TestWorkspaceService {216private _folders: URI[];217218constructor(folders: URI[]) {219super(folders);220this._folders = [...folders];221}222223override getWorkspaceFolders(): URI[] {224return this._folders;225}226227setFolders(folders: URI[]): void {228this._folders = [...folders];229this.didChangeWorkspaceFoldersEmitter.fire({ added: [], removed: [] } as any);230}231}232233function createProviderWithServices(234store: DisposableStore,235workspaceFolders: URI[],236mocks: ReturnType<typeof createDefaultMocks>,237agentManager?: ClaudeAgentManager,238workspaceServiceOverride?: TestWorkspaceService,239): { provider: ClaudeChatSessionContentProvider; accessor: ITestingServicesAccessor } {240const serviceCollection = store.add(createExtensionUnitTestingServices(store));241242const workspaceService = workspaceServiceOverride ?? new TestWorkspaceService(workspaceFolders);243serviceCollection.set(IWorkspaceService, workspaceService);244serviceCollection.set(IGitService, new MockGitService());245246serviceCollection.define(IClaudeCodeSessionService, mocks.mockSessionService);247serviceCollection.define(IChatFolderMruService, mocks.mockFolderMruService);248serviceCollection.define(IClaudeSlashCommandService, {249_serviceBrand: undefined,250tryHandleCommand: vi.fn().mockResolvedValue({ handled: false }),251getRegisteredCommands: vi.fn().mockReturnValue([]),252});253serviceCollection.define(IClaudeCodeSdkService, {254_serviceBrand: undefined,255query: vi.fn(),256listSessions: vi.fn().mockResolvedValue([]),257getSessionInfo: vi.fn().mockResolvedValue(undefined),258getSessionMessages: vi.fn().mockResolvedValue([]),259renameSession: vi.fn().mockResolvedValue(undefined),260forkSession: vi.fn().mockResolvedValue({ sessionId: 'forked' }),261listSubagents: vi.fn().mockResolvedValue([]),262getSubagentMessages: vi.fn().mockResolvedValue([]),263});264serviceCollection.define(IClaudeWorkspaceFolderService, {265_serviceBrand: undefined,266getWorkspaceChanges: vi.fn().mockResolvedValue([]),267});268269const accessor = serviceCollection.createTestingAccessor();270const instaService = accessor.get(IInstantiationService);271const provider = instaService.createInstance(ClaudeChatSessionContentProvider, agentManager ?? createMockAgentManager());272return { provider, accessor };273}274275/**276* Invokes the getChatSessionInputState handler that was set by the provider.277* Pass a sessionResource for existing sessions, or undefined for new sessions.278*/279async function getInputState(280sessionResource?: vscode.Uri,281previousInputState?: vscode.ChatSessionInputState,282): Promise<vscode.ChatSessionInputState> {283if (!lastGetChatSessionInputState) {284throw new Error('getChatSessionInputState handler was not set');285}286return lastGetChatSessionInputState(287sessionResource,288{ previousInputState: previousInputState ?? undefined } as Parameters<vscode.ChatSessionControllerGetInputState>[1],289CancellationToken.None,290);291}292293function getGroup(state: vscode.ChatSessionInputState, groupId: string): vscode.ChatSessionProviderOptionGroup | undefined {294return state.groups.find(g => g.id === groupId);295}296297/**298* Runs the handler for a session and returns the values committed to session state service.299* This is how tests verify permission mode / folder resolution without reaching into internals.300*/301async function runHandlerAndCapture(302contentProvider: ClaudeChatSessionContentProvider,303testAccessor: ITestingServicesAccessor,304sessionId: string,305sessionService: IClaudeCodeSessionService,306options?: { permissionMode?: string; folderPath?: string; allFolderPaths?: string[] },307): Promise<{ permissionMode: string; folderInfo: ClaudeFolderInfo }> {308vi.mocked(sessionService.getSession).mockResolvedValue(undefined);309if (!lastCreatedItemsMap.has(ClaudeSessionUri.forSessionId(sessionId).toString())) {310seedSessionItem(sessionId);311}312313const sessionStateService = testAccessor.get(IClaudeSessionStateService);314const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');315const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');316317const handler = contentProvider.createHandler();318const groups = buildInputStateGroups(options);319const context: vscode.ChatContext = {320history: [],321yieldRequested: false,322chatSessionContext: {323isUntitled: false,324chatSessionItem: {325resource: ClaudeSessionUri.forSessionId(sessionId),326label: 'Test Session',327},328inputState: { groups, sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },329},330} as vscode.ChatContext;331332const stream = new MockChatResponseStream();333await handler(createTestRequest('hello'), context, stream, CancellationToken.None);334335const permissionCall = setPermissionSpy.mock.calls.find(c => c[0] === sessionId);336const folderCall = setFolderInfoSpy.mock.calls.find(c => c[0] === sessionId);337338return {339permissionMode: permissionCall![1],340folderInfo: folderCall![1],341};342}343344describe('ChatSessionContentProvider', () => {345let mockSessionService: IClaudeCodeSessionService;346let mockFolderMruService: MockChatFolderMruService;347let provider: ClaudeChatSessionContentProvider;348const store = new DisposableStore();349let accessor: ITestingServicesAccessor;350const workspaceFolderUri = URI.file('/project');351352beforeEach(() => {353const mocks = createDefaultMocks();354mockSessionService = mocks.mockSessionService;355mockFolderMruService = mocks.mockFolderMruService;356357const result = createProviderWithServices(store, [workspaceFolderUri], mocks);358provider = result.provider;359accessor = result.accessor;360});361362afterEach(() => {363vi.clearAllMocks();364store.clear();365});366367// #region Provider-Level Tests368369describe('provideChatSessionContent', () => {370it('returns empty history when no existing session', async () => {371vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);372373const sessionUri = createClaudeSessionUri('test-session');374const result = await provider.provideChatSessionContent(sessionUri, CancellationToken.None);375376expect(result.history).toEqual([]);377expect(mockSessionService.getSession).toHaveBeenCalledWith(sessionUri, CancellationToken.None);378});379});380381// #endregion382383// #region New Session Input State384385describe('new session input state via getChatSessionInputState', () => {386it('defaults to acceptEdits for permission mode', async () => {387const state = await getInputState();388const permissionGroup = getGroup(state, 'permissionMode');389expect(permissionGroup).toBeDefined();390expect(permissionGroup!.selected?.id).toBe('acceptEdits');391});392393it('restores previous permission mode selection', async () => {394// First, get an initial state and change the permission mode395const initialState = await getInputState();396const permissionGroup = getGroup(initialState, 'permissionMode');397const planItem = permissionGroup!.items.find(i => i.id === 'plan');398initialState.groups = initialState.groups.map(g =>399g.id === 'permissionMode' ? { ...g, selected: planItem } : g400);401402// Now get a new state that restores from the previous one403const restoredState = await getInputState(undefined, initialState);404const restoredGroup = getGroup(restoredState, 'permissionMode');405expect(restoredGroup!.selected?.id).toBe('plan');406});407408it('does not include folder group for single-root workspace', async () => {409const state = await getInputState();410const folderGroup = getGroup(state, 'folder');411expect(folderGroup).toBeUndefined();412});413});414415describe('new session input state in multi-root workspace', () => {416const folderA = URI.file('/project-a');417const folderB = URI.file('/project-b');418419beforeEach(() => {420const mocks = createDefaultMocks();421422createProviderWithServices(store, [folderA, folderB], mocks);423});424425it('includes folder group with default selection for multi-root workspace', async () => {426const state = await getInputState();427const folderGroup = getGroup(state, 'folder');428expect(folderGroup).toBeDefined();429expect(folderGroup!.selected?.id).toBe(folderA.fsPath);430});431});432433// #endregion434435// #region Folder Option Tests436437describe('folder option - single-root workspace', () => {438it('does NOT include folder option group for single-root workspace', async () => {439const state = await getInputState();440const folderGroup = getGroup(state, 'folder');441expect(folderGroup).toBeUndefined();442});443444it('handler commits single workspace folder as cwd', async () => {445const { folderInfo } = await runHandlerAndCapture(provider, accessor, 'test-session', mockSessionService);446expect(folderInfo.cwd).toBe(workspaceFolderUri.fsPath);447expect(folderInfo.additionalDirectories).toEqual([]);448});449450it('does NOT include folder in provideChatSessionContent options', async () => {451vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);452const sessionUri = createClaudeSessionUri('test-session');453const result = await provider.provideChatSessionContent(sessionUri, CancellationToken.None);454expect(result.options?.['folder']).toBeUndefined();455});456});457458describe('folder option - multi-root workspace', () => {459const folderA = URI.file('/project-a');460const folderB = URI.file('/project-b');461const folderC = URI.file('/project-c');462let multiRootProvider: ClaudeChatSessionContentProvider;463let multiRootAccessor: ITestingServicesAccessor;464465beforeEach(() => {466const mocks = createDefaultMocks();467mockSessionService = mocks.mockSessionService;468469mockFolderMruService = mocks.mockFolderMruService;470471const result = createProviderWithServices(store, [folderA, folderB, folderC], mocks);472multiRootProvider = result.provider;473multiRootAccessor = result.accessor;474});475476it('includes folder option group with all workspace folders', async () => {477const state = await getInputState();478const folderGroup = getGroup(state, 'folder');479480expect(folderGroup).toBeDefined();481expect(folderGroup!.items).toHaveLength(3);482expect(folderGroup!.items.map(i => i.id)).toEqual([483folderA.fsPath,484folderB.fsPath,485folderC.fsPath,486]);487});488489it('defaults cwd to first workspace folder when no selection made', async () => {490const { folderInfo } = await runHandlerAndCapture(multiRootProvider, multiRootAccessor, 'test-session', mockSessionService);491expect(folderInfo.cwd).toBe(folderA.fsPath);492expect(folderInfo.additionalDirectories).toEqual([folderB.fsPath, folderC.fsPath]);493});494495it('uses selected folder from inputState as cwd', async () => {496seedSessionItem('test-session');497498const { folderInfo } = await runHandlerAndCapture(multiRootProvider, multiRootAccessor, 'test-session', mockSessionService, {499folderPath: folderB.fsPath,500allFolderPaths: [folderA.fsPath, folderB.fsPath, folderC.fsPath],501});502expect(folderInfo.cwd).toBe(folderB.fsPath);503expect(folderInfo.additionalDirectories).toEqual([folderA.fsPath, folderC.fsPath]);504});505506it('includes default folder in provideChatSessionContent options for new session', async () => {507vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);508const sessionUri = createClaudeSessionUri('test-session');509const result = await multiRootProvider.provideChatSessionContent(sessionUri, CancellationToken.None);510511// Without input state context, options should be empty512expect(result.options).toEqual({});513});514515it('locks folder but not permission mode for existing sessions', async () => {516const session = {517id: 'test-session',518messages: [{519type: 'user',520message: { role: 'user', content: 'Hello' },521}],522subagents: [],523};524vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);525526const sessionUri = createClaudeSessionUri('test-session');527const state = await getInputState(sessionUri);528529const permissionGroup = getGroup(state, 'permissionMode');530expect(permissionGroup).toBeDefined();531expect(permissionGroup!.selected?.locked).toBeUndefined();532expect(permissionGroup!.items.every(i => !i.locked)).toBe(true);533534const folderGroup = getGroup(state, 'folder');535expect(folderGroup).toBeDefined();536expect(folderGroup!.selected?.locked).toBe(true);537expect(folderGroup!.items.every(i => i.locked)).toBe(true);538});539540it('locked folder option preserves the selected folder, not the first one', async () => {541// Set folderB as the session's folder via sessionStateService542const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService);543sessionStateService.setFolderInfoForSession('pre-created-session', {544cwd: folderB.fsPath,545additionalDirectories: [folderA.fsPath],546});547548// Now load the same session as an existing session549const session = {550id: 'pre-created-session',551messages: [{552type: 'user',553message: { role: 'user', content: 'Hello' },554}],555subagents: [],556};557vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);558559const sessionUri = createClaudeSessionUri('pre-created-session');560const state = await getInputState(sessionUri);561const folderGroup = getGroup(state, 'folder');562expect(folderGroup).toBeDefined();563expect(folderGroup!.selected?.locked).toBe(true);564// Should show folder B (the selected folder), not folder A (the first)565expect(folderGroup!.selected?.id).toBe(folderB.fsPath);566});567});568569describe('folder option - empty workspace', () => {570let emptyWorkspaceProvider: ClaudeChatSessionContentProvider;571let emptyMocks: ReturnType<typeof createDefaultMocks>;572let emptyAccessor: ITestingServicesAccessor;573574beforeEach(() => {575emptyMocks = createDefaultMocks();576mockSessionService = emptyMocks.mockSessionService;577mockFolderMruService = emptyMocks.mockFolderMruService;578579const result = createProviderWithServices(store, [], emptyMocks);580emptyWorkspaceProvider = result.provider;581emptyAccessor = result.accessor;582});583584it('includes folder option group with MRU entries', async () => {585const mruFolder = URI.file('/recent/project');586const mruRepo = URI.file('/recent/repo');587mockFolderMruService.setMRUEntries([588{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },589{ folder: mruRepo, repository: mruRepo, lastAccessed: Date.now() - 1000 },590]);591592const state = await getInputState();593const folderGroup = getGroup(state, 'folder');594595expect(folderGroup).toBeDefined();596expect(folderGroup!.items).toHaveLength(2);597expect(folderGroup!.items[0].id).toBe(mruFolder.fsPath);598expect(folderGroup!.items[1].id).toBe(mruRepo.fsPath);599});600601it('shows empty folder options when no MRU entries', async () => {602const state = await getInputState();603const folderGroup = getGroup(state, 'folder');604605expect(folderGroup).toBeDefined();606expect(folderGroup!.items).toHaveLength(0);607});608609it('handler commits MRU fallback folder when no selection', async () => {610const mruFolder = URI.file('/recent/project');611mockFolderMruService.setMRUEntries([612{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },613]);614615const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService);616expect(folderInfo.cwd).toBe(mruFolder.fsPath);617expect(folderInfo.additionalDirectories).toEqual([]);618});619620it('handler commits home directory fallback when no folder available', async () => {621const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService);622expect(folderInfo.cwd).toBe(URI.file('/home/testuser').fsPath);623expect(folderInfo.additionalDirectories).toEqual([]);624});625626it('handler commits selected folder over MRU', async () => {627const mruFolder = URI.file('/recent/project');628const selectedFolder = URI.file('/selected/project');629mockFolderMruService.setMRUEntries([630{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },631]);632633seedSessionItem('test-session');634635const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService, {636folderPath: selectedFolder.fsPath,637});638expect(folderInfo.cwd).toBe(selectedFolder.fsPath);639});640});641642// #endregion643644// #endregion645646// #region Initial Session Options647648describe('initial session options on new sessions', () => {649let mockAgentManager: ClaudeAgentManager;650let handlerProvider: ClaudeChatSessionContentProvider;651let handlerAccessor: ITestingServicesAccessor;652653function createChatContext(sessionId: string, options?: { permissionMode?: string }): vscode.ChatContext {654return {655history: [],656yieldRequested: false,657chatSessionContext: {658isUntitled: false,659chatSessionItem: {660resource: ClaudeSessionUri.forSessionId(sessionId),661label: 'Test Session',662},663inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },664},665} as vscode.ChatContext;666}667668beforeEach(() => {669const mocks = createDefaultMocks();670mockSessionService = mocks.mockSessionService;671672mockFolderMruService = mocks.mockFolderMruService;673mockAgentManager = createMockAgentManager();674675const result = createProviderWithServices(store, [workspaceFolderUri], mocks, mockAgentManager);676handlerProvider = result.provider;677handlerAccessor = result.accessor;678});679680it('sets permission mode from inputState on new session', async () => {681vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);682683seedSessionItem('new-session-1');684685const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);686const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');687688const handler = handlerProvider.createHandler();689const context = createChatContext('new-session-1', { permissionMode: 'plan' });690const stream = new MockChatResponseStream();691692await handler(createTestRequest('hello'), context, stream, CancellationToken.None);693694expect(setPermissionSpy).toHaveBeenCalledWith('new-session-1', 'plan');695});696697it('defaults to acceptEdits when inputState has default permission mode', async () => {698vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);699700seedSessionItem('new-session-2');701702const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);703const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');704705const handler = handlerProvider.createHandler();706const context = createChatContext('new-session-2');707const stream = new MockChatResponseStream();708709await handler(createTestRequest('hello'), context, stream, CancellationToken.None);710711expect(setPermissionSpy).toHaveBeenCalledWith('new-session-2', 'acceptEdits');712});713714it('commits the inputState permission mode to session state service', async () => {715vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);716717seedSessionItem('pre-set-session');718719const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);720const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');721722const handler = handlerProvider.createHandler();723const context = createChatContext('pre-set-session', { permissionMode: 'default' });724const stream = new MockChatResponseStream();725726await handler(createTestRequest('hello'), context, stream, CancellationToken.None);727728expect(setPermissionSpy).toHaveBeenCalledWith('pre-set-session', 'default');729});730731it('commits inputState permission mode on resumed sessions', async () => {732vi.mocked(mockSessionService.getSession).mockResolvedValue({733id: 'existing-session',734messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],735subagents: [],736} as any);737738seedSessionItem('existing-session');739740const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);741const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');742743const handler = handlerProvider.createHandler();744const context = createChatContext('existing-session');745const stream = new MockChatResponseStream();746747await handler(createTestRequest('hello'), context, stream, CancellationToken.None);748749const committedMode = setPermissionSpy.mock.calls.find(c => c[0] === 'existing-session')?.[1];750expect(committedMode).toBe('acceptEdits');751});752});753754describe('initial folder option on new sessions', () => {755const folderA = URI.file('/project-a');756const folderB = URI.file('/project-b');757let mockAgentManager: ClaudeAgentManager;758let multiRootProvider: ClaudeChatSessionContentProvider;759let multiRootAccessor: ITestingServicesAccessor;760761function createChatContext(sessionId: string, options?: { folderPath?: string }): vscode.ChatContext {762return {763history: [],764yieldRequested: false,765chatSessionContext: {766isUntitled: false,767chatSessionItem: {768resource: ClaudeSessionUri.forSessionId(sessionId),769label: 'Test Session',770},771inputState: {772groups: buildInputStateGroups({773folderPath: options?.folderPath,774allFolderPaths: [folderA.fsPath, folderB.fsPath],775}),776sessionResource: undefined,777onDidChange: Event.None,778onDidDispose: Event.None,779},780},781} as vscode.ChatContext;782}783784beforeEach(() => {785const mocks = createDefaultMocks();786mockSessionService = mocks.mockSessionService;787mockAgentManager = createMockAgentManager();788789const result = createProviderWithServices(store, [folderA, folderB], mocks, mockAgentManager);790multiRootProvider = result.provider;791multiRootAccessor = result.accessor;792});793794it('sets folder from inputState on new session', async () => {795vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);796797seedSessionItem('new-folder-session');798799const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService);800const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');801802const handler = multiRootProvider.createHandler();803const context = createChatContext('new-folder-session', { folderPath: folderB.fsPath });804const stream = new MockChatResponseStream();805806await handler(createTestRequest('hello'), context, stream, CancellationToken.None);807808const folderInfo = setFolderInfoSpy.mock.calls.find(c => c[0] === 'new-folder-session')?.[1];809expect(folderInfo?.cwd).toBe(folderB.fsPath);810});811812it('commits inputState folder selection to session state service', async () => {813vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);814815seedSessionItem('pre-folder-session');816817const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService);818const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');819820const handler = multiRootProvider.createHandler();821const context = createChatContext('pre-folder-session', { folderPath: folderA.fsPath });822const stream = new MockChatResponseStream();823824await handler(createTestRequest('hello'), context, stream, CancellationToken.None);825826const folderInfo = setFolderInfoSpy.mock.calls.find(c => c[0] === 'pre-folder-session')?.[1];827expect(folderInfo?.cwd).toBe(folderA.fsPath);828});829});830831// #endregion832833// #region isNewSession Handling834835describe('isNewSession determination via session service', () => {836let mockAgentManager: ClaudeAgentManager;837let handlerProvider: ClaudeChatSessionContentProvider;838839function createChatContext(sessionId: string): vscode.ChatContext {840return {841history: [],842yieldRequested: false,843chatSessionContext: {844isUntitled: false,845chatSessionItem: {846resource: ClaudeSessionUri.forSessionId(sessionId),847label: 'Test Session',848},849inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },850},851} as vscode.ChatContext;852}853854beforeEach(() => {855const mocks = createDefaultMocks();856mockSessionService = mocks.mockSessionService;857858mockFolderMruService = mocks.mockFolderMruService;859mockAgentManager = createMockAgentManager();860861const result = createProviderWithServices(store, [workspaceFolderUri], mocks, mockAgentManager);862handlerProvider = result.provider;863});864865it('treats session as new when no session exists on disk', async () => {866vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);867seedSessionItem('real-uuid-123');868869const handler = handlerProvider.createHandler();870const context = createChatContext('real-uuid-123');871const stream = new MockChatResponseStream();872873await handler(createTestRequest('hello'), context, stream, CancellationToken.None);874875const handleRequestMock = vi.mocked(mockAgentManager.handleRequest);876expect(handleRequestMock).toHaveBeenCalledOnce();877878const [sessionId, , , , isNewSession] = handleRequestMock.mock.calls[0];879expect(sessionId).toBe('real-uuid-123');880expect(isNewSession).toBe(true);881});882883it('treats session as resumed when session exists on disk', async () => {884seedSessionItem('real-uuid-123');885vi.mocked(mockSessionService.getSession).mockResolvedValue({886id: 'real-uuid-123',887messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],888subagents: [],889} as any);890891const handler = handlerProvider.createHandler();892const context = createChatContext('real-uuid-123');893const stream = new MockChatResponseStream();894895await handler(createTestRequest('hello'), context, stream, CancellationToken.None);896897const handleRequestMock = vi.mocked(mockAgentManager.handleRequest);898expect(handleRequestMock).toHaveBeenCalledOnce();899900const [sessionId, , , , isNewSession] = handleRequestMock.mock.calls[0];901expect(sessionId).toBe('real-uuid-123');902expect(isNewSession).toBe(false);903});904905it('second request is not treated as new when session exists on disk', async () => {906seedSessionItem('real-uuid-123');907const handler = handlerProvider.createHandler();908const stream = new MockChatResponseStream();909910// First request: no session on disk yet → new session911vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);912const firstContext = createChatContext('real-uuid-123');913await handler(createTestRequest('first'), firstContext, stream, CancellationToken.None);914915// Second request: session now exists on disk → resumed916vi.mocked(mockSessionService.getSession).mockResolvedValue({917id: 'real-uuid-123',918messages: [{ type: 'user', message: { role: 'user', content: 'first' } }],919subagents: [],920} as any);921const secondContext = createChatContext('real-uuid-123');922await handler(createTestRequest('second'), secondContext, stream, CancellationToken.None);923924const handleRequestMock = vi.mocked(mockAgentManager.handleRequest);925const [, , , , secondIsNew] = handleRequestMock.mock.calls[1];926expect(secondIsNew).toBe(false);927});928});929930// #endregion931932// #region Handler Integration933934describe('handler integration', () => {935let mockAgentManager: ClaudeAgentManager;936let handlerProvider: ClaudeChatSessionContentProvider;937let handlerAccessor: ITestingServicesAccessor;938939function createChatContext(sessionId: string): vscode.ChatContext {940return {941history: [],942yieldRequested: false,943chatSessionContext: {944isUntitled: false,945chatSessionItem: {946resource: ClaudeSessionUri.forSessionId(sessionId),947label: 'Test Session',948},949inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },950},951} as vscode.ChatContext;952}953954beforeEach(() => {955const mocks = createDefaultMocks();956mockSessionService = mocks.mockSessionService;957958mockFolderMruService = mocks.mockFolderMruService;959mockAgentManager = createMockAgentManager();960961const result = createProviderWithServices(store, [workspaceFolderUri], mocks, mockAgentManager);962handlerProvider = result.provider;963handlerAccessor = result.accessor;964});965966it('commits request.model.id to session state service', async () => {967vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);968seedSessionItem('session-1');969970const handler = handlerProvider.createHandler();971const context = createChatContext('session-1');972const stream = new MockChatResponseStream();973974const mockSessionStateService = handlerAccessor.get(IClaudeSessionStateService);975const setModelSpy = vi.spyOn(mockSessionStateService, 'setModelIdForSession');976977await handler(createTestRequest('hello'), context, stream, CancellationToken.None);978979expect(setModelSpy).toHaveBeenCalledWith('session-1', parseClaudeModelId('claude-3-5-sonnet-20241022'));980});981982it('short-circuits before session resolution when slash command is handled', async () => {983const slashCommandService = handlerAccessor.get(IClaudeSlashCommandService);984vi.mocked(slashCommandService.tryHandleCommand).mockResolvedValue({985handled: true,986result: { metadata: { command: '/test' } },987} as any);988989const handler = handlerProvider.createHandler();990const context = createChatContext('session-1');991const stream = new MockChatResponseStream();992993const result = await handler(new TestChatRequest('/test'), context, stream, CancellationToken.None);994995// Slash command handled → no agent call996expect(vi.mocked(mockAgentManager.handleRequest)).not.toHaveBeenCalled();997expect(result).toEqual({ metadata: { command: '/test' } });998});999});10001001// #endregion10021003// #region Observable pipeline reactivity10041005/**1006* These tests drive the input-state observable pipeline end-to-end via the1007* external signals it observes (config change, workspace folder change,1008* session-state change, session start) and assert the resulting1009* `state.groups` reflect each event. This is the "series of events" testing1010* the observable refactor was designed to enable.1011*/1012describe('observable pipeline reactivity', () => {1013const folderA = URI.file('/project-a');1014const folderB = URI.file('/project-b');10151016async function flushMicrotasks(): Promise<void> {1017// Autoruns that schedule async work (e.g. MRU fetch when workspace goes empty)1018// settle on the microtask queue. Two ticks covers chained thenables.1019await Promise.resolve();1020await Promise.resolve();1021}10221023it('toggling bypass-permissions config adds/removes the bypass item reactively', async () => {1024const mocks = createDefaultMocks();1025const { accessor: localAccessor } = createProviderWithServices(store, [folderA, folderB], mocks);1026const configService = localAccessor.get(IConfigurationService);10271028const state = await getInputState();1029let permissionGroup = getGroup(state, 'permissionMode')!;1030expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions');10311032await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, true);1033permissionGroup = getGroup(state, 'permissionMode')!;1034expect(permissionGroup.items.map(i => i.id)).toContain('bypassPermissions');10351036await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, false);1037permissionGroup = getGroup(state, 'permissionMode')!;1038expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions');1039});10401041it('workspace folder changes reshape the folder group', async () => {1042const mocks = createDefaultMocks();1043const mutableWs = new MutableWorkspaceService([folderA, folderB]);1044createProviderWithServices(store, [], mocks, undefined, mutableWs);10451046const state = await getInputState();1047let folderGroup = getGroup(state, 'folder');1048expect(folderGroup).toBeDefined();1049expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);10501051// Add a third folder1052const folderC = URI.file('/project-c');1053mutableWs.setFolders([folderA, folderB, folderC]);1054folderGroup = getGroup(state, 'folder');1055expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath, folderC.fsPath]);10561057// Transition to a single folder → group hides1058mutableWs.setFolders([folderA]);1059folderGroup = getGroup(state, 'folder');1060expect(folderGroup).toBeUndefined();10611062// Back to multi-root1063mutableWs.setFolders([folderA, folderB]);1064folderGroup = getGroup(state, 'folder');1065expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);1066});10671068it('emptying the workspace falls back to MRU items', async () => {1069const mocks = createDefaultMocks();1070const mutableWs = new MutableWorkspaceService([folderA, folderB]);1071const mruFolder = URI.file('/recent/project');1072mocks.mockFolderMruService.setMRUEntries([1073{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },1074]);1075createProviderWithServices(store, [], mocks, undefined, mutableWs);10761077const state = await getInputState();1078mutableWs.setFolders([]);1079await flushMicrotasks();10801081const folderGroup = getGroup(state, 'folder');1082expect(folderGroup).toBeDefined();1083expect(folderGroup!.items.map(i => i.id)).toEqual([mruFolder.fsPath]);1084});10851086it('external session-state permission change syncs into the input state', async () => {1087const mocks = createDefaultMocks();1088const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);1089const sessionStateService = localAccessor.get(IClaudeSessionStateService);10901091// Mark as existing so the pipeline wires up the external permission autorun1092const existingSession = { id: 'external-session', messages: [], subagents: [] };1093vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any);10941095const sessionUri = createClaudeSessionUri('external-session');1096const state = await getInputState(sessionUri);1097expect(getGroup(state, 'permissionMode')!.selected?.id).not.toBe('plan');10981099sessionStateService.setPermissionModeForSession('external-session', 'plan');1100expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('plan');11011102sessionStateService.setPermissionModeForSession('external-session', 'default');1103expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default');1104});11051106it('live permission option changes update session state', async () => {1107const mocks = createDefaultMocks();1108const { provider, accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);1109const sessionStateService = localAccessor.get(IClaudeSessionStateService);1110const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');11111112provider.provideHandleOptionsChange(createClaudeSessionUri('live-session'), [1113{ optionId: 'permissionMode', value: 'plan' }1114], CancellationToken.None);11151116expect(setPermissionSpy).toHaveBeenCalledWith('live-session', 'plan');1117expect(sessionStateService.getPermissionModeForSession('live-session')).toBe('plan');1118});11191120it('external permission change syncs into a previousInputState-restored pipeline', async () => {1121const mocks = createDefaultMocks();1122const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);1123const sessionStateService = localAccessor.get(IClaudeSessionStateService);11241125const existingSession = { id: 'prev-state-session', messages: [], subagents: [] };1126vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any);11271128const sessionUri = createClaudeSessionUri('prev-state-session');1129const firstState = await getInputState(sessionUri);11301131// Simulate getChatSessionInputState being called again with previousInputState1132// (e.g. user refocuses the chat window). The pipeline is rebuilt from scratch.1133const restoredState = await getInputState(sessionUri, firstState);1134expect(getGroup(restoredState, 'permissionMode')!.selected?.id).not.toBe('plan');11351136// Permission mode changes externally (e.g. EnterPlanMode tool call)1137sessionStateService.setPermissionModeForSession('prev-state-session', 'plan');1138expect(getGroup(restoredState, 'permissionMode')!.selected?.id).toBe('plan');11391140sessionStateService.setPermissionModeForSession('prev-state-session', 'acceptEdits');1141expect(getGroup(restoredState, 'permissionMode')!.selected?.id).toBe('acceptEdits');1142});11431144it('sessionResource locks the folder group for existing sessions', async () => {1145const mocks = createDefaultMocks();1146createProviderWithServices(store, [folderA, folderB], mocks);11471148// New session (no sessionResource) — folder is unlocked1149const newState = await getInputState();1150let folderGroup = getGroup(newState, 'folder')!;1151expect(folderGroup.items.every(i => !i.locked)).toBe(true);1152expect(folderGroup.selected?.locked).toBeUndefined();11531154// Existing session (sessionResource provided) — folder is locked1155vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue({1156id: 'started-session',1157messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],1158subagents: [],1159} as any);1160const sessionUri = createClaudeSessionUri('started-session');1161const startedState = await getInputState(sessionUri);11621163folderGroup = getGroup(startedState, 'folder')!;1164expect(folderGroup.items.every(i => i.locked === true)).toBe(true);1165expect(folderGroup.selected?.locked).toBe(true);1166});11671168it('restoring a locked previousInputState preserves the lock across workspace changes', async () => {1169const mocks = createDefaultMocks();1170const mutableWs = new MutableWorkspaceService([folderA, folderB]);1171createProviderWithServices(store, [], mocks, undefined, mutableWs);11721173// First state — mark it as started to get locked items1174const initialState = await getInputState();1175const initialGroup = getGroup(initialState, 'folder')!;1176// Synthesize a locked previousInputState (matching what a started session looks like)1177const lockedGroups: vscode.ChatSessionProviderOptionGroup[] = initialState.groups.map(g =>1178g.id === 'folder'1179? {1180...g,1181items: g.items.map(i => ({ ...i, locked: true })),1182selected: g.selected ? { ...g.selected, locked: true } : undefined,1183}1184: g1185);1186const lockedPrevious: vscode.ChatSessionInputState = {1187groups: lockedGroups,1188sessionResource: undefined,1189onDidChange: Event.None,1190onDidDispose: Event.None,1191};1192// sanity check1193expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);11941195// Restore from the locked previous state1196const restoredState = await getInputState(undefined, lockedPrevious);1197let restoredGroup = getGroup(restoredState, 'folder')!;1198expect(restoredGroup.items.every(i => i.locked === true)).toBe(true);11991200// Now workspace folders change — lock must persist1201const folderC = URI.file('/project-c');1202mutableWs.setFolders([folderA, folderB, folderC]);1203restoredGroup = getGroup(restoredState, 'folder')!;1204expect(restoredGroup.items).toHaveLength(3);1205expect(restoredGroup.items.every(i => i.locked === true)).toBe(true);1206});1207});12081209// #endregion1210});12111212// #region FakeGitService12131214/**1215* A git service mock with event emitters that can be fired in tests.1216* Unlike MockGitService, this supports onDidOpenRepository event firing.1217*/1218class FakeGitService extends mock<IGitService>() {1219private readonly _onDidOpenRepository = new Emitter<RepoContext>();1220override readonly onDidOpenRepository = this._onDidOpenRepository.event;12211222private readonly _onDidCloseRepository = new Emitter<RepoContext>();1223override readonly onDidCloseRepository = this._onDidCloseRepository.event;12241225override readonly onDidFinishInitialization: Event<void> = Event.None;12261227override repositories: RepoContext[] = [];1228override isInitialized = true;12291230fireOpenRepository(repo: RepoContext): void {1231this._onDidOpenRepository.fire(repo);1232}12331234fireCloseRepository(repo: RepoContext): void {1235this._onDidCloseRepository.fire(repo);1236}12371238override dispose(): void {1239super.dispose();1240this._onDidOpenRepository.dispose();1241this._onDidCloseRepository.dispose();1242}1243}12441245// #endregion12461247// #region Test helpers12481249function buildRepoContext(overrides: {1250rootUri?: URI;1251headBranchName?: string;1252upstreamRemote?: string;1253upstreamBranchName?: string;1254headIncomingChanges?: number;1255headOutgoingChanges?: number;1256changes?: RepoContext['changes'];1257remoteFetchUrls?: Array<string | undefined>;1258} = {}): RepoContext {1259return {1260rootUri: overrides.rootUri ?? URI.file('/project'),1261kind: 'repository',1262isUsingVirtualFileSystem: false,1263headIncomingChanges: overrides.headIncomingChanges ?? 0,1264headOutgoingChanges: overrides.headOutgoingChanges ?? 0,1265headBranchName: overrides.headBranchName ?? 'main',1266headCommitHash: 'abc123',1267upstreamBranchName: overrides.upstreamBranchName,1268upstreamRemote: overrides.upstreamRemote,1269isRebasing: false,1270remoteFetchUrls: overrides.remoteFetchUrls ?? [],1271remotes: [],1272worktrees: [],1273changes: overrides.changes,1274headBranchNameObs: observableValue('test', overrides.headBranchName ?? 'main'),1275headCommitHashObs: observableValue('test', 'abc123'),1276upstreamBranchNameObs: observableValue('test', overrides.upstreamBranchName),1277upstreamRemoteObs: observableValue('test', overrides.upstreamRemote),1278isRebasingObs: observableValue('test', false),1279isIgnored: () => Promise.resolve(false),1280};1281}12821283const MockChange = mock<Change>();1284function mockChange(): Change {1285return new MockChange();1286}12871288function findCommandHandler(commandId: string): (...args: unknown[]) => Promise<void> {1289const calls = vi.mocked(vscodeShim.commands.registerCommand).mock.calls;1290const matchingCalls = calls.filter(c => c[0] === commandId);1291const call = matchingCalls[matchingCalls.length - 1];1292if (!call) {1293throw new Error(`Command ${commandId} was not registered`);1294}1295return call[1];1296}12971298function buildDiskSession(id: string, overrides: Partial<IClaudeCodeSessionInfo> = {}): IClaudeCodeSessionInfo {1299return {1300id,1301label: id,1302created: Date.now(),1303lastRequestEnded: Date.now(),1304folderName: 'my-project',1305cwd: '/home/user/my-project',1306...overrides,1307} as IClaudeCodeSessionInfo;1308}13091310// #endregion13111312describe('ClaudeChatSessionItemController', () => {1313const store = new DisposableStore();1314let mockSessionService: IClaudeCodeSessionService;1315let mockSdkService: IClaudeCodeSdkService;1316let controller: ClaudeChatSessionItemController;1317let lastControllerAccessor: ITestingServicesAccessor;13181319function getItem(sessionId: string): vscode.ChatSessionItem | undefined {1320return lastCreatedItemsMap.get(ClaudeSessionUri.forSessionId(sessionId).toString());1321}13221323function createController(workspaceFolders: URI[], gitService?: IGitService): ClaudeChatSessionItemController {1324const serviceCollection = store.add(createExtensionUnitTestingServices());1325const workspaceService = new TestWorkspaceService(workspaceFolders);1326serviceCollection.set(IWorkspaceService, workspaceService);1327serviceCollection.set(IGitService, gitService ?? new MockGitService());1328serviceCollection.define(IClaudeCodeSessionService, mockSessionService);1329serviceCollection.define(IChatFolderMruService, new MockChatFolderMruService());1330mockSdkService = {1331_serviceBrand: undefined,1332query: vi.fn(),1333listSessions: vi.fn().mockResolvedValue([]),1334getSessionInfo: vi.fn().mockResolvedValue(undefined),1335getSessionMessages: vi.fn().mockResolvedValue([]),1336renameSession: vi.fn().mockResolvedValue(undefined),1337forkSession: vi.fn().mockResolvedValue({ sessionId: 'forked-session-id' }),1338listSubagents: vi.fn().mockResolvedValue([]),1339getSubagentMessages: vi.fn().mockResolvedValue([]),1340};1341serviceCollection.define(IClaudeCodeSdkService, mockSdkService);1342serviceCollection.define(IClaudeWorkspaceFolderService, {1343_serviceBrand: undefined,1344getWorkspaceChanges: vi.fn().mockResolvedValue([]),1345});1346const accessor = serviceCollection.createTestingAccessor();1347lastControllerAccessor = accessor;1348const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController);1349store.add(ctrl);1350return ctrl;1351}13521353beforeEach(() => {1354mockSessionService = {1355_serviceBrand: undefined,1356getSession: vi.fn().mockResolvedValue(undefined),1357getAllSessions: vi.fn().mockResolvedValue([]),1358} as unknown as IClaudeCodeSessionService;1359});13601361afterEach(() => {1362vi.clearAllMocks();1363store.clear();1364});13651366// #region updateItemStatus13671368describe('updateItemStatus', () => {1369beforeEach(() => {1370controller = createController([URI.file('/project')]);1371});13721373it('creates a new item with the provided label when no disk session exists', async () => {1374await controller.updateItemStatus('new-session', ChatSessionStatus.InProgress, 'Hello world');13751376const item = getItem('new-session');1377expect(item).toBeDefined();1378expect(item!.label).toBe('Hello world');1379expect(item!.status).toBe(ChatSessionStatus.InProgress);1380});13811382it('sets timing.lastRequestStarted and clears lastRequestEnded for InProgress', async () => {1383const before = Date.now();1384await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');1385const after = Date.now();13861387const item = getItem('session-1');1388expect(item!.timing).toBeDefined();1389expect(item!.timing!.lastRequestStarted).toBeGreaterThanOrEqual(before);1390expect(item!.timing!.lastRequestStarted).toBeLessThanOrEqual(after);1391expect(item!.timing!.lastRequestEnded).toBeUndefined();1392});13931394it('sets timing.lastRequestEnded for Completed status', async () => {1395await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');13961397const beforeComplete = Date.now();1398await controller.updateItemStatus('session-1', ChatSessionStatus.Completed, 'Test prompt');1399const afterComplete = Date.now();14001401const item = getItem('session-1');1402expect(item!.timing!.lastRequestEnded).toBeGreaterThanOrEqual(beforeComplete);1403expect(item!.timing!.lastRequestEnded).toBeLessThanOrEqual(afterComplete);1404});14051406it('clears lastRequestEnded on second InProgress after Completed', async () => {1407await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');1408await controller.updateItemStatus('session-1', ChatSessionStatus.Completed, 'Test prompt');1409await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');14101411const item = getItem('session-1');1412expect(item!.timing!.lastRequestEnded).toBeUndefined();1413expect(item!.timing!.lastRequestStarted).toBeDefined();1414});14151416it('creates timing with lastRequestEnded when Completed is called without prior InProgress', async () => {1417const before = Date.now();1418await controller.updateItemStatus('session-1', ChatSessionStatus.Completed, 'Test prompt');1419const after = Date.now();14201421const item = getItem('session-1');1422expect(item!.timing).toBeDefined();1423expect(item!.timing!.created).toBeGreaterThanOrEqual(before);1424expect(item!.timing!.created).toBeLessThanOrEqual(after);1425expect(item!.timing!.lastRequestEnded).toBeGreaterThanOrEqual(before);1426expect(item!.timing!.lastRequestEnded).toBeLessThanOrEqual(after);1427});14281429it('uses session data from disk when available', async () => {1430const diskSession: IClaudeCodeSessionInfo = {1431id: 'disk-session',1432label: 'Disk Session Label',1433created: new Date('2024-01-01T00:00:00Z').getTime(),1434lastRequestEnded: new Date('2024-01-01T01:00:00Z').getTime(),1435folderName: 'my-project',1436};1437vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);14381439await controller.updateItemStatus('disk-session', ChatSessionStatus.InProgress, 'Ignored label');14401441const item = getItem('disk-session');1442expect(item).toBeDefined();1443expect(item!.label).toBe('Disk Session Label');1444expect(item!.tooltip).toBe('Claude Code session: Disk Session Label');14451446expect(mockSessionService.getSession).toHaveBeenCalledOnce();1447const [calledUri] = vi.mocked(mockSessionService.getSession).mock.calls[0];1448expect(calledUri.scheme).toBe('claude-code');1449expect(calledUri.path).toBe('/disk-session');1450});14511452it('handles multiple independent sessions', async () => {1453await controller.updateItemStatus('session-a', ChatSessionStatus.InProgress, 'Prompt A');1454await controller.updateItemStatus('session-b', ChatSessionStatus.InProgress, 'Prompt B');1455await controller.updateItemStatus('session-a', ChatSessionStatus.Completed, 'Prompt A');14561457const itemA = getItem('session-a');1458const itemB = getItem('session-b');1459expect(itemA!.status).toBe(ChatSessionStatus.Completed);1460expect(itemB!.status).toBe(ChatSessionStatus.InProgress);1461});14621463it('calls getWorkspaceChanges on Completed status when session has cwd', async () => {1464const diskSession: IClaudeCodeSessionInfo = {1465id: 'changes-session',1466label: 'Changes Session',1467created: Date.now(),1468lastRequestEnded: Date.now(),1469folderName: 'my-project',1470cwd: '/home/user/my-project',1471gitBranch: 'feature-branch',1472};1473vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);14741475const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }];1476const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);1477vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any);14781479await controller.updateItemStatus('changes-session', ChatSessionStatus.InProgress, 'Prompt');1480await controller.updateItemStatus('changes-session', ChatSessionStatus.Completed, 'Prompt');14811482expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith(1483'/home/user/my-project',1484'feature-branch',1485undefined,1486true,1487);1488const item = getItem('changes-session');1489expect(item!.changes).toBe(mockChanges);1490});14911492it('does not call getWorkspaceChanges on Completed when session has no cwd', async () => {1493const diskSession: IClaudeCodeSessionInfo = {1494id: 'no-cwd',1495label: 'No CWD',1496created: Date.now(),1497lastRequestEnded: Date.now(),1498folderName: undefined,1499};1500vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);15011502const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);15031504await controller.updateItemStatus('no-cwd', ChatSessionStatus.InProgress, 'Prompt');1505await controller.updateItemStatus('no-cwd', ChatSessionStatus.Completed, 'Prompt');15061507expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalled();1508});15091510it('does not call getWorkspaceChanges with forceRefresh on InProgress status', async () => {1511const diskSession: IClaudeCodeSessionInfo = {1512id: 'in-progress',1513label: 'In Progress',1514created: Date.now(),1515lastRequestEnded: Date.now(),1516folderName: 'my-project',1517cwd: '/home/user/my-project',1518gitBranch: 'feature-branch',1519};1520vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);15211522const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);15231524await controller.updateItemStatus('in-progress', ChatSessionStatus.InProgress, 'Prompt');15251526expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalledWith(1527expect.anything(),1528expect.anything(),1529expect.anything(),1530true,1531);1532});1533});15341535// #endregion15361537// #region Session item properties15381539describe('session item properties', () => {1540beforeEach(() => {1541controller = createController([URI.file('/project')]);1542});15431544it('sets resource with correct scheme and path', async () => {1545await controller.updateItemStatus('my-session', ChatSessionStatus.InProgress, 'hello');15461547const item = getItem('my-session');1548expect(item!.resource.scheme).toBe('claude-code');1549expect(item!.resource.path).toBe('/my-session');1550});15511552it('sets tooltip to formatted session name', async () => {1553await controller.updateItemStatus('my-session', ChatSessionStatus.InProgress, 'fix the bug');15541555const item = getItem('my-session');1556expect(item!.tooltip).toBe('Claude Code session: fix the bug');1557});15581559it('sets iconPath to claude ThemeIcon', async () => {1560await controller.updateItemStatus('my-session', ChatSessionStatus.InProgress, 'hello');15611562const item = getItem('my-session');1563expect(item!.iconPath).toBeDefined();1564expect(item!.iconPath).toBeInstanceOf(ThemeIcon);1565expect((item!.iconPath as ThemeIcon).id).toBe('claude');1566});15671568it('uses disk session label and timestamps when available', async () => {1569const diskSession: IClaudeCodeSessionInfo = {1570id: 'disk-session',1571label: 'Disk Label',1572created: new Date('2024-06-01T12:00:00Z').getTime(),1573lastRequestEnded: new Date('2024-06-01T13:00:00Z').getTime(),1574folderName: undefined,1575};1576vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);15771578await controller.updateItemStatus('disk-session', ChatSessionStatus.InProgress, 'Prompt');15791580const item = getItem('disk-session');1581expect(item!.label).toBe('Disk Label');1582expect(item!.tooltip).toBe('Claude Code session: Disk Label');1583// timing.created is derived from created1584expect(item!.timing!.created).toBe(new Date('2024-06-01T12:00:00Z').getTime());1585});15861587it('sets metadata with workingDirectoryPath when session has cwd', async () => {1588const diskSession: IClaudeCodeSessionInfo = {1589id: 'cwd-session',1590label: 'CWD Session',1591created: Date.now(),1592lastRequestEnded: Date.now(),1593folderName: 'my-project',1594cwd: '/home/user/my-project',1595};1596vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);15971598await controller.updateItemStatus('cwd-session', ChatSessionStatus.InProgress, 'Prompt');15991600const item = getItem('cwd-session');1601expect(item!.metadata).toEqual({ workingDirectoryPath: '/home/user/my-project' });1602});16031604it('does not set metadata when session has no cwd', async () => {1605await controller.updateItemStatus('no-cwd-session', ChatSessionStatus.InProgress, 'Prompt');16061607const item = getItem('no-cwd-session');1608expect(item!.metadata).toBeUndefined();1609});16101611it('populates item.changes when session has cwd and gitBranch', async () => {1612const diskSession: IClaudeCodeSessionInfo = {1613id: 'changes-item',1614label: 'Changes Item',1615created: Date.now(),1616lastRequestEnded: Date.now(),1617folderName: 'my-project',1618cwd: '/home/user/my-project',1619gitBranch: 'feature-branch',1620};1621vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);16221623const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }];1624const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);1625vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any);16261627await controller.updateItemStatus('changes-item', ChatSessionStatus.InProgress, 'Prompt');16281629expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith(1630'/home/user/my-project',1631'feature-branch',1632undefined,1633);1634const item = getItem('changes-item');1635expect(item!.changes).toBe(mockChanges);1636});1637});16381639// #endregion16401641// #region Badge visibility16421643describe('badge visibility', () => {1644it('does not show badge in single-root workspace with zero repos', async () => {1645controller = createController([URI.file('/project')]);16461647const sessionInfo: IClaudeCodeSessionInfo = {1648id: 'test',1649label: 'Test',1650created: Date.now(),1651lastRequestEnded: Date.now(),1652folderName: 'project',1653};1654vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);16551656await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');16571658const item = getItem('test');1659expect(item!.badge).toBeUndefined();1660});16611662it('shows badge in multi-root workspace', async () => {1663controller = createController([URI.file('/project-a'), URI.file('/project-b')]);16641665const sessionInfo: IClaudeCodeSessionInfo = {1666id: 'test',1667label: 'Test',1668created: Date.now(),1669lastRequestEnded: Date.now(),1670folderName: 'project-a',1671};1672vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);16731674await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');16751676const item = getItem('test');1677expect(item!.badge).toBeDefined();1678expect(item!.badge).toBeInstanceOf(MarkdownString);1679expect((item!.badge as MarkdownString).value).toBe('$(folder) project-a');1680});16811682it('shows badge in empty workspace', async () => {1683controller = createController([]);16841685const sessionInfo: IClaudeCodeSessionInfo = {1686id: 'test',1687label: 'Test',1688created: Date.now(),1689lastRequestEnded: Date.now(),1690folderName: 'my-folder',1691};1692vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);16931694await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');16951696const item = getItem('test');1697expect(item!.badge).toBeDefined();1698expect((item!.badge as MarkdownString).value).toBe('$(folder) my-folder');1699});17001701it('badge has supportThemeIcons set to true', async () => {1702controller = createController([URI.file('/a'), URI.file('/b')]);17031704const sessionInfo: IClaudeCodeSessionInfo = {1705id: 'test',1706label: 'Test',1707created: Date.now(),1708lastRequestEnded: Date.now(),1709folderName: 'project',1710};1711vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);17121713await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');17141715const item = getItem('test');1716expect((item!.badge as MarkdownString).supportThemeIcons).toBe(true);1717});17181719it('badge is undefined when session has no folderName', async () => {1720controller = createController([URI.file('/a'), URI.file('/b')]);17211722await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');17231724const item = getItem('test');1725// No disk session → no folderName → no badge even though multi-root1726expect(item!.badge).toBeUndefined();1727});17281729it('different sessions show their own folder names', async () => {1730controller = createController([URI.file('/a'), URI.file('/b')]);17311732vi.mocked(mockSessionService.getSession)1733.mockResolvedValueOnce({1734id: 'session-1', label: 'S1',1735created: Date.now(), lastRequestEnded: Date.now(),1736folderName: 'frontend',1737} as any)1738.mockResolvedValueOnce({1739id: 'session-2', label: 'S2',1740created: Date.now(), lastRequestEnded: Date.now(),1741folderName: 'backend',1742} as any);17431744await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'S1');1745await controller.updateItemStatus('session-2', ChatSessionStatus.InProgress, 'S2');17461747expect((getItem('session-1')!.badge as MarkdownString).value).toBe('$(folder) frontend');1748expect((getItem('session-2')!.badge as MarkdownString).value).toBe('$(folder) backend');1749});17501751it('shows badge in single-root workspace with multiple non-worktree repos', async () => {1752const fakeGit = new FakeGitService();1753fakeGit.repositories = [1754{ rootUri: URI.file('/project/repo1'), kind: 'repository' } as unknown as RepoContext,1755{ rootUri: URI.file('/project/repo2'), kind: 'repository' } as unknown as RepoContext,1756];1757controller = createController([URI.file('/project')], fakeGit);17581759const sessionInfo: IClaudeCodeSessionInfo = {1760id: 'test', label: 'Test',1761created: Date.now(), lastRequestEnded: Date.now(),1762folderName: 'repo1',1763};1764vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);17651766await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');17671768const item = getItem('test');1769expect(item!.badge).toBeDefined();1770expect((item!.badge as MarkdownString).value).toBe('$(folder) repo1');1771});17721773it('does not show badge when extra repos are worktrees', async () => {1774const fakeGit = new FakeGitService();1775fakeGit.repositories = [1776{ rootUri: URI.file('/project/main'), kind: 'repository' } as unknown as RepoContext,1777{ rootUri: URI.file('/project/wt'), kind: 'worktree' } as unknown as RepoContext,1778];1779controller = createController([URI.file('/project')], fakeGit);17801781const sessionInfo: IClaudeCodeSessionInfo = {1782id: 'test', label: 'Test',1783created: Date.now(), lastRequestEnded: Date.now(),1784folderName: 'main',1785};1786vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);17871788await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');17891790const item = getItem('test');1791// Only 1 non-worktree repo → no badge1792expect(item!.badge).toBeUndefined();1793});1794});17951796// #endregion17971798// #region Git event refresh17991800describe('git event refresh', () => {1801it('recomputes badge when a repository opens', async () => {1802const fakeGit = new FakeGitService();1803fakeGit.repositories = [];1804controller = createController([URI.file('/project')], fakeGit);18051806const sessionInfo: IClaudeCodeSessionInfo = {1807id: 'test', label: 'Test',1808created: Date.now(), lastRequestEnded: Date.now(),1809folderName: 'repo1',1810};1811vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);1812vi.mocked(mockSessionService.getAllSessions).mockResolvedValue([sessionInfo]);18131814// Initially no repos → single-root with 0 repos, _computeShowBadge returns false1815await controller.updateItemStatus('test', ChatSessionStatus.Completed, 'hello');1816expect(getItem('test')!.badge).toBeUndefined();18171818// Now simulate two repos opening (monorepo scenario)1819const repo1 = { rootUri: URI.file('/project/r1'), kind: 'repository' } as unknown as RepoContext;1820const repo2 = { rootUri: URI.file('/project/r2'), kind: 'repository' } as unknown as RepoContext;1821fakeGit.repositories = [repo1, repo2];1822fakeGit.fireOpenRepository(repo2);18231824// Flush microtask queue so the async _refreshItems completes.1825await new Promise(r => setTimeout(r, 0));18261827const refreshedItem = getItem('test');1828expect(refreshedItem).toBeDefined();1829expect(refreshedItem!.badge).toBeDefined();1830expect((refreshedItem!.badge as MarkdownString).value).toBe('$(folder) repo1');1831});18321833it('recomputes badge when a repository closes', async () => {1834const fakeGit = new FakeGitService();1835const repo1 = { rootUri: URI.file('/project/r1'), kind: 'repository' } as unknown as RepoContext;1836const repo2 = { rootUri: URI.file('/project/r2'), kind: 'repository' } as unknown as RepoContext;1837fakeGit.repositories = [repo1, repo2];1838controller = createController([URI.file('/project')], fakeGit);18391840const sessionInfo: IClaudeCodeSessionInfo = {1841id: 'test', label: 'Test',1842created: Date.now(), lastRequestEnded: Date.now(),1843folderName: 'repo1',1844};1845vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);1846vi.mocked(mockSessionService.getAllSessions).mockResolvedValue([sessionInfo]);18471848await controller.updateItemStatus('test', ChatSessionStatus.Completed, 'hello');1849expect(getItem('test')!.badge).toBeDefined();18501851// Close one repo → single non-worktree repo → badge should disappear1852fakeGit.repositories = [repo1];1853fakeGit.fireCloseRepository(repo2);18541855// Flush microtask queue so the async _refreshItems completes.1856await new Promise(r => setTimeout(r, 0));18571858const refreshedItem = getItem('test');1859expect(refreshedItem).toBeDefined();1860expect(refreshedItem!.badge).toBeUndefined();1861});18621863it('preserves in-progress items after refresh', async () => {1864const fakeGit = new FakeGitService();1865fakeGit.repositories = [];1866controller = createController([URI.file('/project')], fakeGit);18671868const sessionInfo: IClaudeCodeSessionInfo = {1869id: 'test', label: 'Test',1870created: Date.now(), lastRequestEnded: Date.now(),1871folderName: 'repo1',1872};1873vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);1874vi.mocked(mockSessionService.getAllSessions).mockResolvedValue([sessionInfo]);18751876await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');1877const itemBeforeRefresh = getItem('test');1878expect(itemBeforeRefresh).toBeDefined();1879expect(itemBeforeRefresh!.status).toBe(ChatSessionStatus.InProgress);18801881// Trigger a refresh via git event1882const repo1 = { rootUri: URI.file('/project/r1'), kind: 'repository' } as unknown as RepoContext;1883fakeGit.repositories = [repo1];1884fakeGit.fireOpenRepository(repo1);18851886await new Promise(r => setTimeout(r, 0));18871888const refreshedItem = getItem('test');1889expect(refreshedItem).toBeDefined();1890expect(refreshedItem!.status).toBe(ChatSessionStatus.InProgress);1891});1892});18931894// #endregion18951896// #endregion18971898// #region forkHandler18991900describe('forkHandler', () => {1901beforeEach(() => {1902controller = createController([URI.file('/project')]);1903});19041905function makeSession(id: string, messages: Array<{ uuid: string; type: string }>) {1906return {1907id,1908label: 'Test session',1909created: Date.now(),1910lastRequestEnded: Date.now(),1911messages: messages.map(m => ({1912...m,1913sessionId: id,1914timestamp: new Date(),1915parentUuid: null,1916message: {},1917})),1918subagents: [],1919};1920}19211922it('forks whole history when no request is specified', async () => {1923const sessionResource = ClaudeSessionUri.forSessionId('sess-1');1924lastCreatedItemsMap.set(sessionResource.toString(), {1925resource: sessionResource,1926label: 'Original',1927});19281929const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None);19301931expect(mockSdkService.forkSession).toHaveBeenCalledWith('sess-1', { upToMessageId: undefined, title: expect.any(String) });1932expect(result.resource.toString()).toContain('forked-session-id');1933expect(result.label).toContain('Forked');1934});19351936it('copies session state from parent to forked session', async () => {1937const sessionResource = ClaudeSessionUri.forSessionId('sess-1');1938lastCreatedItemsMap.set(sessionResource.toString(), {1939resource: sessionResource,1940label: 'Original',1941});19421943// Seed the parent session with non-default state1944const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);1945sessionStateService.setPermissionModeForSession('sess-1', 'plan');1946sessionStateService.setFolderInfoForSession('sess-1', {1947cwd: '/custom/folder',1948additionalDirectories: ['/extra'],1949});19501951const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');1952const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');19531954await lastForkHandler!(sessionResource, undefined, CancellationToken.None);19551956expect(setPermissionSpy).toHaveBeenCalledWith('forked-session-id', 'plan');1957expect(setFolderInfoSpy).toHaveBeenCalledWith('forked-session-id', {1958cwd: '/custom/folder',1959additionalDirectories: ['/extra'],1960});1961});19621963it('forks at the message before the specified request', async () => {1964const sessionResource = ClaudeSessionUri.forSessionId('sess-1');1965lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });19661967const session = makeSession('sess-1', [1968{ uuid: 'msg-1', type: 'user' },1969{ uuid: 'msg-2', type: 'assistant' },1970{ uuid: 'msg-3', type: 'user' },1971]);1972vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);19731974const request = { id: 'msg-3', prompt: 'test' } as vscode.ChatRequestTurn2;1975await lastForkHandler!(sessionResource, request, CancellationToken.None);19761977expect(mockSdkService.forkSession).toHaveBeenCalledWith('sess-1', { upToMessageId: 'msg-2', title: expect.any(String) });1978});19791980it('throws when session is not found for a specific request fork', async () => {1981const sessionResource = ClaudeSessionUri.forSessionId('sess-1');1982lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });1983vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);19841985const request = { id: 'msg-1', prompt: 'test' } as vscode.ChatRequestTurn2;1986await expect(lastForkHandler!(sessionResource, request, CancellationToken.None)).rejects.toThrow(/session not found/i);1987});19881989it('throws when request message is not found in session', async () => {1990const sessionResource = ClaudeSessionUri.forSessionId('sess-1');1991lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });19921993const session = makeSession('sess-1', [{ uuid: 'msg-1', type: 'user' }]);1994vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);19951996const request = { id: 'nonexistent', prompt: 'test' } as vscode.ChatRequestTurn2;1997await expect(lastForkHandler!(sessionResource, request, CancellationToken.None)).rejects.toThrow(/could not be found/i);1998});19992000it('throws when trying to fork at the first message', async () => {2001const sessionResource = ClaudeSessionUri.forSessionId('sess-1');2002lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });20032004const session = makeSession('sess-1', [{ uuid: 'msg-1', type: 'user' }]);2005vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);20062007const request = { id: 'msg-1', prompt: 'test' } as vscode.ChatRequestTurn2;2008await expect(lastForkHandler!(sessionResource, request, CancellationToken.None)).rejects.toThrow(/first message/i);2009});20102011it('adds the forked item to the controller items', async () => {2012const sessionResource = ClaudeSessionUri.forSessionId('sess-1');2013lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });20142015await lastForkHandler!(sessionResource, undefined, CancellationToken.None);20162017const forkedItem = getItem('forked-session-id');2018expect(forkedItem).toBeDefined();2019expect(forkedItem!.iconPath).toBeDefined();2020expect(forkedItem!.timing).toBeDefined();2021});2022});20232024// #endregion20252026// #region Session metadata enrichment20272028describe('session metadata enrichment', () => {2029it('includes enriched git metadata when repository exists', async () => {2030const gitService = new MockGitService();2031const repoCtx = buildRepoContext({2032rootUri: URI.file('/home/user/my-project'),2033headBranchName: 'feature-branch',2034upstreamRemote: 'origin',2035upstreamBranchName: 'feature-branch',2036headIncomingChanges: 2,2037headOutgoingChanges: 3,2038remoteFetchUrls: ['https://github.com/owner/repo.git'],2039changes: {2040mergeChanges: [],2041indexChanges: [mockChange(), mockChange()],2042workingTree: [mockChange()],2043untrackedChanges: [],2044},2045});2046vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);2047controller = createController([URI.file('/project')], gitService);20482049const diskSession = buildDiskSession('enriched-meta');2050vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);20512052await controller.updateItemStatus('enriched-meta', ChatSessionStatus.InProgress, 'Prompt');20532054const item = getItem('enriched-meta');2055expect(item!.metadata).toEqual({2056workingDirectoryPath: '/home/user/my-project',2057repositoryPath: URI.file('/home/user/my-project').fsPath,2058branchName: 'feature-branch',2059upstreamBranchName: 'origin/feature-branch',2060hasGitHubRemote: true,2061incomingChanges: 2,2062outgoingChanges: 3,2063uncommittedChanges: 3,2064});2065});20662067it('sets upstreamBranchName to undefined when no upstream remote', async () => {2068const gitService = new MockGitService();2069const repoCtx = buildRepoContext({2070rootUri: URI.file('/home/user/my-project'),2071headBranchName: 'local-only',2072upstreamRemote: undefined,2073upstreamBranchName: undefined,2074});2075vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);2076controller = createController([URI.file('/project')], gitService);20772078const diskSession = buildDiskSession('no-upstream');2079vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);20802081await controller.updateItemStatus('no-upstream', ChatSessionStatus.InProgress, 'Prompt');20822083const item = getItem('no-upstream');2084expect(item!.metadata).toMatchObject({2085branchName: 'local-only',2086upstreamBranchName: undefined,2087});2088});20892090it('sums uncommittedChanges from all change categories', async () => {2091const gitService = new MockGitService();2092const repoCtx = buildRepoContext({2093rootUri: URI.file('/home/user/my-project'),2094changes: {2095mergeChanges: [mockChange(), mockChange()],2096indexChanges: [mockChange(), mockChange(), mockChange()],2097workingTree: [mockChange()],2098untrackedChanges: [mockChange(), mockChange(), mockChange(), mockChange()],2099},2100});2101vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);2102controller = createController([URI.file('/project')], gitService);21032104const diskSession = buildDiskSession('many-changes');2105vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);21062107await controller.updateItemStatus('many-changes', ChatSessionStatus.InProgress, 'Prompt');21082109const item = getItem('many-changes');2110expect(item!.metadata).toMatchObject({ uncommittedChanges: 10 });2111});21122113it('sets uncommittedChanges to 0 when changes is undefined', async () => {2114const gitService = new MockGitService();2115const repoCtx = buildRepoContext({2116rootUri: URI.file('/home/user/my-project'),2117changes: undefined,2118});2119vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);2120controller = createController([URI.file('/project')], gitService);21212122const diskSession = buildDiskSession('no-changes');2123vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);21242125await controller.updateItemStatus('no-changes', ChatSessionStatus.InProgress, 'Prompt');21262127const item = getItem('no-changes');2128expect(item!.metadata).toMatchObject({ uncommittedChanges: 0 });2129});21302131it('sets hasGitHubRemote to false when no GitHub remote', async () => {2132const gitService = new MockGitService();2133const repoCtx = buildRepoContext({2134rootUri: URI.file('/home/user/my-project'),2135remoteFetchUrls: ['https://gitlab.com/owner/repo.git'],2136});2137vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);2138controller = createController([URI.file('/project')], gitService);21392140const diskSession = buildDiskSession('no-github');2141vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);21422143await controller.updateItemStatus('no-github', ChatSessionStatus.InProgress, 'Prompt');21442145const item = getItem('no-github');2146expect(item!.metadata).toMatchObject({ hasGitHubRemote: false });2147});2148});21492150// #endregion21512152// #region Command handlers21532154describe('command handlers', () => {2155it('commit command sends /commit prompt to the session', async () => {2156createController([URI.file('/project')]);2157const resource = ClaudeSessionUri.forSessionId('test-session');21582159await findCommandHandler('github.copilot.claude.sessions.commit')(resource);21602161expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(2162'workbench.action.chat.openSessionWithPrompt.claude-code',2163{ resource, prompt: builtinSlashCommands.commit },2164);2165});21662167it('commitAndSync command sends combined /commit and /sync prompt', async () => {2168createController([URI.file('/project')]);2169const resource = ClaudeSessionUri.forSessionId('test-session');21702171await findCommandHandler('github.copilot.claude.sessions.commitAndSync')(resource);21722173expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(2174'workbench.action.chat.openSessionWithPrompt.claude-code',2175{ resource, prompt: `${builtinSlashCommands.commit} and ${builtinSlashCommands.sync}` },2176);2177});21782179it('sync command sends /sync prompt to the session', async () => {2180createController([URI.file('/project')]);2181const resource = ClaudeSessionUri.forSessionId('test-session');21822183await findCommandHandler('github.copilot.claude.sessions.sync')(resource);21842185expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(2186'workbench.action.chat.openSessionWithPrompt.claude-code',2187{ resource, prompt: builtinSlashCommands.sync },2188);2189});21902191it('commit command extracts resource from ChatSessionItem', async () => {2192createController([URI.file('/project')]);2193const resource = ClaudeSessionUri.forSessionId('test-session');2194const sessionItem = { resource, label: 'Test' };21952196await findCommandHandler('github.copilot.claude.sessions.commit')(sessionItem);21972198expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(2199'workbench.action.chat.openSessionWithPrompt.claude-code',2200{ resource, prompt: builtinSlashCommands.commit },2201);2202});22032204it('commands do not execute when resource is undefined', async () => {2205createController([URI.file('/project')]);22062207await findCommandHandler('github.copilot.claude.sessions.commit')(undefined);2208await findCommandHandler('github.copilot.claude.sessions.commitAndSync')(undefined);2209await findCommandHandler('github.copilot.claude.sessions.sync')(undefined);22102211expect(vscodeShim.commands.executeCommand).not.toHaveBeenCalled();2212});22132214it('initializeRepository calls gitService.initRepository with workspace folder', async () => {2215const gitService = new MockGitService();2216const initSpy = vi.spyOn(gitService, 'initRepository').mockResolvedValue({} as Repository);2217controller = createController([], gitService);22182219const sessionId = 'init-repo-session';2220const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);2221sessionStateService.setFolderInfoForSession(sessionId, {2222cwd: '/home/user/my-project',2223additionalDirectories: [],2224});22252226const resource = ClaudeSessionUri.forSessionId(sessionId);2227await findCommandHandler('github.copilot.claude.sessions.initializeRepository')(resource);22282229expect(initSpy).toHaveBeenCalledWith(URI.file('/home/user/my-project'));2230});22312232it('initializeRepository does not throw when init returns undefined', async () => {2233const gitService = new MockGitService();2234vi.spyOn(gitService, 'initRepository').mockResolvedValue(undefined);2235controller = createController([], gitService);22362237const sessionId = 'init-fail-session';2238const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);2239sessionStateService.setFolderInfoForSession(sessionId, {2240cwd: '/home/user/my-project',2241additionalDirectories: [],2242});22432244const resource = ClaudeSessionUri.forSessionId(sessionId);2245await findCommandHandler('github.copilot.claude.sessions.initializeRepository')(resource);2246});2247});22482249// #endregion2250});22512252function createClaudeSessionUri(id: string): URI {2253return URI.parse(`claude-code:/${id}`);2254}225522562257