Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
7
import * as path from 'path';
8
import type * as vscode from 'vscode';
9
// eslint-disable-next-line no-duplicate-imports
10
import * as vscodeShim from 'vscode';
11
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
12
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
13
import { Change, Repository } from '../../../../platform/git/vscode/git';
14
import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';
15
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
16
import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';
17
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
18
import { mock } from '../../../../util/common/test/simpleMock';
19
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
20
import { Emitter, Event } from '../../../../util/vs/base/common/event';
21
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
22
import { observableValue } from '../../../../util/vs/base/common/observableInternal/observables/observableValue';
23
import { URI } from '../../../../util/vs/base/common/uri';
24
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
25
import { ChatSessionStatus, MarkdownString, ThemeIcon } from '../../../../vscodeTypes';
26
import { createExtensionUnitTestingServices } from '../../../test/node/services';
27
import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';
28
import { ClaudeFolderInfo } from '../../claude/common/claudeFolderInfo';
29
import { ClaudeSessionUri } from '../../claude/common/claudeSessionUri';
30
import type { ClaudeAgentManager } from '../../claude/node/claudeCodeAgent';
31
import { IClaudeCodeSdkService } from '../../claude/node/claudeCodeSdkService';
32
import { parseClaudeModelId } from '../../claude/node/claudeModelId';
33
import { IClaudeSessionStateService } from '../../claude/common/claudeSessionStateService';
34
import { IClaudeCodeSessionService } from '../../claude/node/sessionParser/claudeCodeSessionService';
35
import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSessionSchema';
36
import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService';
37
import { FolderRepositoryMRUEntry, IChatFolderMruService } from '../../common/folderRepositoryManager';
38
import { IClaudeWorkspaceFolderService } from '../../common/claudeWorkspaceFolderService';
39
import { builtinSlashCommands } from '../../common/builtinSlashCommands';
40
import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider';
41
42
// Expose the most recently created items map so tests can inspect controller items.
43
let lastCreatedItemsMap: Map<string, vscode.ChatSessionItem>;
44
// Expose the most recently registered fork handler so tests can invoke it directly.
45
let lastForkHandler: ((sessionResource: vscode.Uri, request: vscode.ChatRequestTurn2 | undefined, token: CancellationToken) => Thenable<vscode.ChatSessionItem>) | undefined;
46
// Expose the most recently registered getChatSessionInputState handler so tests can invoke it.
47
let lastGetChatSessionInputState: vscode.ChatSessionControllerGetInputState | undefined;
48
49
// Patch vscode shim with missing namespaces before any production code imports it.
50
beforeAll(() => {
51
(vscodeShim as Record<string, unknown>).commands = {
52
registerCommand: vi.fn().mockReturnValue({ dispose: () => { } }),
53
executeCommand: vi.fn().mockResolvedValue(undefined),
54
};
55
(vscodeShim as Record<string, unknown>).chat = {
56
createChatSessionItemController: () => {
57
const itemsMap = new Map<string, vscode.ChatSessionItem>();
58
lastCreatedItemsMap = itemsMap;
59
lastForkHandler = undefined;
60
lastGetChatSessionInputState = undefined;
61
return {
62
id: 'claude-code',
63
items: {
64
get: (resource: URI) => itemsMap.get(resource.toString()),
65
add: (item: vscode.ChatSessionItem) => { itemsMap.set(item.resource.toString(), item); },
66
delete: (resource: URI) => { itemsMap.delete(resource.toString()); },
67
replace: (items: vscode.ChatSessionItem[]) => {
68
itemsMap.clear();
69
for (const item of items) {
70
itemsMap.set(item.resource.toString(), item);
71
}
72
},
73
get size() { return itemsMap.size; },
74
[Symbol.iterator]: function* () { yield* itemsMap.values(); },
75
forEach: (cb: (item: vscode.ChatSessionItem) => void) => { itemsMap.forEach(cb); },
76
},
77
createChatSessionItem: (resource: unknown, label: string) => ({
78
resource,
79
label,
80
}),
81
createChatSessionInputState: (groups: vscode.ChatSessionProviderOptionGroup[]) => {
82
const emitter = new Emitter<void>();
83
const state: vscode.ChatSessionInputState = {
84
groups,
85
sessionResource: undefined,
86
onDidChange: emitter.event,
87
onDidDispose: Event.None,
88
};
89
// Proxy that fires onDidChange when groups are replaced
90
return new Proxy(state, {
91
set(target, prop, value) {
92
(target as any)[prop] = value;
93
if (prop === 'groups') {
94
emitter.fire();
95
}
96
return true;
97
},
98
});
99
},
100
set getChatSessionInputState(handler: vscode.ChatSessionControllerGetInputState) { lastGetChatSessionInputState = handler; },
101
set forkHandler(handler: typeof lastForkHandler) { lastForkHandler = handler; },
102
refreshHandler: () => Promise.resolve(),
103
dispose: () => { },
104
onDidArchiveChatSessionItem: () => ({ dispose: () => { } }),
105
};
106
},
107
};
108
});
109
110
class MockChatFolderMruService implements IChatFolderMruService {
111
declare _serviceBrand: undefined;
112
113
private _mruEntries: FolderRepositoryMRUEntry[] = [];
114
115
setMRUEntries(entries: FolderRepositoryMRUEntry[]): void {
116
this._mruEntries = entries;
117
}
118
119
async getRecentlyUsedFolders(): Promise<FolderRepositoryMRUEntry[]> {
120
return this._mruEntries;
121
}
122
123
async deleteRecentlyUsedFolder(): Promise<void> { }
124
}
125
126
function createDefaultMocks() {
127
const mockSessionService: IClaudeCodeSessionService = {
128
getSession: vi.fn()
129
} as any;
130
131
const mockFolderMruService = new MockChatFolderMruService();
132
133
return { mockSessionService, mockFolderMruService };
134
}
135
136
function createMockAgentManager(): ClaudeAgentManager {
137
return {
138
handleRequest: vi.fn().mockResolvedValue({}),
139
} as unknown as ClaudeAgentManager;
140
}
141
142
/** Creates a TestChatRequest with a mock model that has an id property */
143
function createTestRequest(prompt: string): TestChatRequest {
144
const request = new TestChatRequest(prompt);
145
(request as any).model = { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', family: 'claude' };
146
return request;
147
}
148
149
/**
150
* Adds a session item to the controller's items map.
151
* This simulates what newChatSessionItemHandler does when VS Code creates a new session.
152
*/
153
function seedSessionItem(sessionId: string): void {
154
const resource = ClaudeSessionUri.forSessionId(sessionId);
155
const item: vscode.ChatSessionItem = {
156
resource,
157
label: sessionId,
158
};
159
lastCreatedItemsMap.set(resource.toString(), item);
160
}
161
162
/**
163
* Builds a minimal permission mode input state group with the given mode selected.
164
* Defaults to 'acceptEdits' if no mode specified.
165
*/
166
function buildPermissionModeGroup(selectedMode: string = 'acceptEdits'): vscode.ChatSessionProviderOptionGroup {
167
const items = [
168
{ id: 'default', name: 'Ask before edits' },
169
{ id: 'acceptEdits', name: 'Edit automatically' },
170
{ id: 'plan', name: 'Plan mode' },
171
{ id: 'bypassPermissions', name: 'Yolo mode' },
172
{ id: 'dontAsk', name: 'Don\'t ask' },
173
];
174
const selected = items.find(i => i.id === selectedMode) ?? items[1];
175
return {
176
id: 'permissionMode',
177
name: 'Permission Mode',
178
items,
179
selected: { ...selected },
180
};
181
}
182
183
/**
184
* Builds a minimal folder input state group with the given folder selected.
185
*/
186
function buildFolderGroup(selectedFolderPath: string, allFolderPaths?: string[]): vscode.ChatSessionProviderOptionGroup {
187
const paths = allFolderPaths ?? [selectedFolderPath];
188
const items = paths.map(p => ({ id: p, name: path.basename(p) }));
189
const selected = items.find(i => i.id === selectedFolderPath) ?? items[0];
190
return {
191
id: 'folder',
192
name: 'Folder',
193
items,
194
selected: { ...selected },
195
};
196
}
197
198
/**
199
* Builds inputState groups for test chat contexts.
200
* Always includes a permission mode group. Folder group is added when folderPath is provided.
201
*/
202
function buildInputStateGroups(options?: { permissionMode?: string; folderPath?: string; allFolderPaths?: string[] }): vscode.ChatSessionProviderOptionGroup[] {
203
const groups: vscode.ChatSessionProviderOptionGroup[] = [
204
buildPermissionModeGroup(options?.permissionMode),
205
];
206
if (options?.folderPath) {
207
groups.push(buildFolderGroup(options.folderPath, options.allFolderPaths));
208
}
209
return groups;
210
}
211
212
/**
213
* Workspace service whose folder list can be mutated at runtime so tests can
214
* exercise folder-change events through the observable pipeline.
215
*/
216
class MutableWorkspaceService extends TestWorkspaceService {
217
private _folders: URI[];
218
219
constructor(folders: URI[]) {
220
super(folders);
221
this._folders = [...folders];
222
}
223
224
override getWorkspaceFolders(): URI[] {
225
return this._folders;
226
}
227
228
setFolders(folders: URI[]): void {
229
this._folders = [...folders];
230
this.didChangeWorkspaceFoldersEmitter.fire({ added: [], removed: [] } as any);
231
}
232
}
233
234
function createProviderWithServices(
235
store: DisposableStore,
236
workspaceFolders: URI[],
237
mocks: ReturnType<typeof createDefaultMocks>,
238
agentManager?: ClaudeAgentManager,
239
workspaceServiceOverride?: TestWorkspaceService,
240
): { provider: ClaudeChatSessionContentProvider; accessor: ITestingServicesAccessor } {
241
const serviceCollection = store.add(createExtensionUnitTestingServices(store));
242
243
const workspaceService = workspaceServiceOverride ?? new TestWorkspaceService(workspaceFolders);
244
serviceCollection.set(IWorkspaceService, workspaceService);
245
serviceCollection.set(IGitService, new MockGitService());
246
247
serviceCollection.define(IClaudeCodeSessionService, mocks.mockSessionService);
248
serviceCollection.define(IChatFolderMruService, mocks.mockFolderMruService);
249
serviceCollection.define(IClaudeSlashCommandService, {
250
_serviceBrand: undefined,
251
tryHandleCommand: vi.fn().mockResolvedValue({ handled: false }),
252
getRegisteredCommands: vi.fn().mockReturnValue([]),
253
});
254
serviceCollection.define(IClaudeCodeSdkService, {
255
_serviceBrand: undefined,
256
query: vi.fn(),
257
listSessions: vi.fn().mockResolvedValue([]),
258
getSessionInfo: vi.fn().mockResolvedValue(undefined),
259
getSessionMessages: vi.fn().mockResolvedValue([]),
260
renameSession: vi.fn().mockResolvedValue(undefined),
261
forkSession: vi.fn().mockResolvedValue({ sessionId: 'forked' }),
262
listSubagents: vi.fn().mockResolvedValue([]),
263
getSubagentMessages: vi.fn().mockResolvedValue([]),
264
});
265
serviceCollection.define(IClaudeWorkspaceFolderService, {
266
_serviceBrand: undefined,
267
getWorkspaceChanges: vi.fn().mockResolvedValue([]),
268
});
269
270
const accessor = serviceCollection.createTestingAccessor();
271
const instaService = accessor.get(IInstantiationService);
272
const provider = instaService.createInstance(ClaudeChatSessionContentProvider, agentManager ?? createMockAgentManager());
273
return { provider, accessor };
274
}
275
276
/**
277
* Invokes the getChatSessionInputState handler that was set by the provider.
278
* Pass a sessionResource for existing sessions, or undefined for new sessions.
279
*/
280
async function getInputState(
281
sessionResource?: vscode.Uri,
282
previousInputState?: vscode.ChatSessionInputState,
283
): Promise<vscode.ChatSessionInputState> {
284
if (!lastGetChatSessionInputState) {
285
throw new Error('getChatSessionInputState handler was not set');
286
}
287
return lastGetChatSessionInputState(
288
sessionResource,
289
{ previousInputState: previousInputState ?? undefined } as Parameters<vscode.ChatSessionControllerGetInputState>[1],
290
CancellationToken.None,
291
);
292
}
293
294
function getGroup(state: vscode.ChatSessionInputState, groupId: string): vscode.ChatSessionProviderOptionGroup | undefined {
295
return state.groups.find(g => g.id === groupId);
296
}
297
298
/**
299
* Runs the handler for a session and returns the values committed to session state service.
300
* This is how tests verify permission mode / folder resolution without reaching into internals.
301
*/
302
async function runHandlerAndCapture(
303
contentProvider: ClaudeChatSessionContentProvider,
304
testAccessor: ITestingServicesAccessor,
305
sessionId: string,
306
sessionService: IClaudeCodeSessionService,
307
options?: { permissionMode?: string; folderPath?: string; allFolderPaths?: string[] },
308
): Promise<{ permissionMode: string; folderInfo: ClaudeFolderInfo }> {
309
vi.mocked(sessionService.getSession).mockResolvedValue(undefined);
310
if (!lastCreatedItemsMap.has(ClaudeSessionUri.forSessionId(sessionId).toString())) {
311
seedSessionItem(sessionId);
312
}
313
314
const sessionStateService = testAccessor.get(IClaudeSessionStateService);
315
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
316
const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');
317
318
const handler = contentProvider.createHandler();
319
const groups = buildInputStateGroups(options);
320
const context: vscode.ChatContext = {
321
history: [],
322
yieldRequested: false,
323
chatSessionContext: {
324
isUntitled: false,
325
chatSessionItem: {
326
resource: ClaudeSessionUri.forSessionId(sessionId),
327
label: 'Test Session',
328
},
329
inputState: { groups, sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
330
},
331
} as vscode.ChatContext;
332
333
const stream = new MockChatResponseStream();
334
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
335
336
const permissionCall = setPermissionSpy.mock.calls.find(c => c[0] === sessionId);
337
const folderCall = setFolderInfoSpy.mock.calls.find(c => c[0] === sessionId);
338
339
return {
340
permissionMode: permissionCall![1],
341
folderInfo: folderCall![1],
342
};
343
}
344
345
describe('ChatSessionContentProvider', () => {
346
let mockSessionService: IClaudeCodeSessionService;
347
let mockFolderMruService: MockChatFolderMruService;
348
let provider: ClaudeChatSessionContentProvider;
349
const store = new DisposableStore();
350
let accessor: ITestingServicesAccessor;
351
const workspaceFolderUri = URI.file('/project');
352
353
beforeEach(() => {
354
const mocks = createDefaultMocks();
355
mockSessionService = mocks.mockSessionService;
356
mockFolderMruService = mocks.mockFolderMruService;
357
358
const result = createProviderWithServices(store, [workspaceFolderUri], mocks);
359
provider = result.provider;
360
accessor = result.accessor;
361
});
362
363
afterEach(() => {
364
vi.clearAllMocks();
365
store.clear();
366
});
367
368
// #region Provider-Level Tests
369
370
describe('provideChatSessionContent', () => {
371
it('returns empty history when no existing session', async () => {
372
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
373
374
const sessionUri = createClaudeSessionUri('test-session');
375
const result = await provider.provideChatSessionContent(sessionUri, CancellationToken.None);
376
377
expect(result.history).toEqual([]);
378
expect(mockSessionService.getSession).toHaveBeenCalledWith(sessionUri, CancellationToken.None);
379
});
380
});
381
382
// #endregion
383
384
// #region New Session Input State
385
386
describe('new session input state via getChatSessionInputState', () => {
387
it('defaults to acceptEdits for permission mode', async () => {
388
const state = await getInputState();
389
const permissionGroup = getGroup(state, 'permissionMode');
390
expect(permissionGroup).toBeDefined();
391
expect(permissionGroup!.selected?.id).toBe('acceptEdits');
392
});
393
394
it('restores previous permission mode selection', async () => {
395
// First, get an initial state and change the permission mode
396
const initialState = await getInputState();
397
const permissionGroup = getGroup(initialState, 'permissionMode');
398
const planItem = permissionGroup!.items.find(i => i.id === 'plan');
399
initialState.groups = initialState.groups.map(g =>
400
g.id === 'permissionMode' ? { ...g, selected: planItem } : g
401
);
402
403
// Now get a new state that restores from the previous one
404
const restoredState = await getInputState(undefined, initialState);
405
const restoredGroup = getGroup(restoredState, 'permissionMode');
406
expect(restoredGroup!.selected?.id).toBe('plan');
407
});
408
409
it('does not include folder group for single-root workspace', async () => {
410
const state = await getInputState();
411
const folderGroup = getGroup(state, 'folder');
412
expect(folderGroup).toBeUndefined();
413
});
414
});
415
416
describe('new session input state in multi-root workspace', () => {
417
const folderA = URI.file('/project-a');
418
const folderB = URI.file('/project-b');
419
420
beforeEach(() => {
421
const mocks = createDefaultMocks();
422
423
createProviderWithServices(store, [folderA, folderB], mocks);
424
});
425
426
it('includes folder group with default selection for multi-root workspace', async () => {
427
const state = await getInputState();
428
const folderGroup = getGroup(state, 'folder');
429
expect(folderGroup).toBeDefined();
430
expect(folderGroup!.selected?.id).toBe(folderA.fsPath);
431
});
432
});
433
434
// #endregion
435
436
// #region Folder Option Tests
437
438
describe('folder option - single-root workspace', () => {
439
it('does NOT include folder option group for single-root workspace', async () => {
440
const state = await getInputState();
441
const folderGroup = getGroup(state, 'folder');
442
expect(folderGroup).toBeUndefined();
443
});
444
445
it('handler commits single workspace folder as cwd', async () => {
446
const { folderInfo } = await runHandlerAndCapture(provider, accessor, 'test-session', mockSessionService);
447
expect(folderInfo.cwd).toBe(workspaceFolderUri.fsPath);
448
expect(folderInfo.additionalDirectories).toEqual([]);
449
});
450
451
it('does NOT include folder in provideChatSessionContent options', async () => {
452
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
453
const sessionUri = createClaudeSessionUri('test-session');
454
const result = await provider.provideChatSessionContent(sessionUri, CancellationToken.None);
455
expect(result.options?.['folder']).toBeUndefined();
456
});
457
});
458
459
describe('folder option - multi-root workspace', () => {
460
const folderA = URI.file('/project-a');
461
const folderB = URI.file('/project-b');
462
const folderC = URI.file('/project-c');
463
let multiRootProvider: ClaudeChatSessionContentProvider;
464
let multiRootAccessor: ITestingServicesAccessor;
465
466
beforeEach(() => {
467
const mocks = createDefaultMocks();
468
mockSessionService = mocks.mockSessionService;
469
470
mockFolderMruService = mocks.mockFolderMruService;
471
472
const result = createProviderWithServices(store, [folderA, folderB, folderC], mocks);
473
multiRootProvider = result.provider;
474
multiRootAccessor = result.accessor;
475
});
476
477
it('includes folder option group with all workspace folders', async () => {
478
const state = await getInputState();
479
const folderGroup = getGroup(state, 'folder');
480
481
expect(folderGroup).toBeDefined();
482
expect(folderGroup!.items).toHaveLength(3);
483
expect(folderGroup!.items.map(i => i.id)).toEqual([
484
folderA.fsPath,
485
folderB.fsPath,
486
folderC.fsPath,
487
]);
488
});
489
490
it('defaults cwd to first workspace folder when no selection made', async () => {
491
const { folderInfo } = await runHandlerAndCapture(multiRootProvider, multiRootAccessor, 'test-session', mockSessionService);
492
expect(folderInfo.cwd).toBe(folderA.fsPath);
493
expect(folderInfo.additionalDirectories).toEqual([folderB.fsPath, folderC.fsPath]);
494
});
495
496
it('uses selected folder from inputState as cwd', async () => {
497
seedSessionItem('test-session');
498
499
const { folderInfo } = await runHandlerAndCapture(multiRootProvider, multiRootAccessor, 'test-session', mockSessionService, {
500
folderPath: folderB.fsPath,
501
allFolderPaths: [folderA.fsPath, folderB.fsPath, folderC.fsPath],
502
});
503
expect(folderInfo.cwd).toBe(folderB.fsPath);
504
expect(folderInfo.additionalDirectories).toEqual([folderA.fsPath, folderC.fsPath]);
505
});
506
507
it('includes default folder in provideChatSessionContent options for new session', async () => {
508
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
509
const sessionUri = createClaudeSessionUri('test-session');
510
const result = await multiRootProvider.provideChatSessionContent(sessionUri, CancellationToken.None);
511
512
// Without input state context, options should be empty
513
expect(result.options).toEqual({});
514
});
515
516
it('locks folder but not permission mode for existing sessions', async () => {
517
const session = {
518
id: 'test-session',
519
messages: [{
520
type: 'user',
521
message: { role: 'user', content: 'Hello' },
522
}],
523
subagents: [],
524
};
525
vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);
526
527
const sessionUri = createClaudeSessionUri('test-session');
528
const state = await getInputState(sessionUri);
529
530
const permissionGroup = getGroup(state, 'permissionMode');
531
expect(permissionGroup).toBeDefined();
532
expect(permissionGroup!.selected?.locked).toBeUndefined();
533
expect(permissionGroup!.items.every(i => !i.locked)).toBe(true);
534
535
const folderGroup = getGroup(state, 'folder');
536
expect(folderGroup).toBeDefined();
537
expect(folderGroup!.selected?.locked).toBe(true);
538
expect(folderGroup!.items.every(i => i.locked)).toBe(true);
539
});
540
541
it('locked folder option preserves the selected folder, not the first one', async () => {
542
// Set folderB as the session's folder via sessionStateService
543
const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService);
544
sessionStateService.setFolderInfoForSession('pre-created-session', {
545
cwd: folderB.fsPath,
546
additionalDirectories: [folderA.fsPath],
547
});
548
549
// Now load the same session as an existing session
550
const session = {
551
id: 'pre-created-session',
552
messages: [{
553
type: 'user',
554
message: { role: 'user', content: 'Hello' },
555
}],
556
subagents: [],
557
};
558
vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);
559
560
const sessionUri = createClaudeSessionUri('pre-created-session');
561
const state = await getInputState(sessionUri);
562
const folderGroup = getGroup(state, 'folder');
563
expect(folderGroup).toBeDefined();
564
expect(folderGroup!.selected?.locked).toBe(true);
565
// Should show folder B (the selected folder), not folder A (the first)
566
expect(folderGroup!.selected?.id).toBe(folderB.fsPath);
567
});
568
});
569
570
describe('folder option - empty workspace', () => {
571
let emptyWorkspaceProvider: ClaudeChatSessionContentProvider;
572
let emptyMocks: ReturnType<typeof createDefaultMocks>;
573
let emptyAccessor: ITestingServicesAccessor;
574
575
beforeEach(() => {
576
emptyMocks = createDefaultMocks();
577
mockSessionService = emptyMocks.mockSessionService;
578
mockFolderMruService = emptyMocks.mockFolderMruService;
579
580
const result = createProviderWithServices(store, [], emptyMocks);
581
emptyWorkspaceProvider = result.provider;
582
emptyAccessor = result.accessor;
583
});
584
585
it('includes folder option group with MRU entries', async () => {
586
const mruFolder = URI.file('/recent/project');
587
const mruRepo = URI.file('/recent/repo');
588
mockFolderMruService.setMRUEntries([
589
{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },
590
{ folder: mruRepo, repository: mruRepo, lastAccessed: Date.now() - 1000 },
591
]);
592
593
const state = await getInputState();
594
const folderGroup = getGroup(state, 'folder');
595
596
expect(folderGroup).toBeDefined();
597
expect(folderGroup!.items).toHaveLength(2);
598
expect(folderGroup!.items[0].id).toBe(mruFolder.fsPath);
599
expect(folderGroup!.items[1].id).toBe(mruRepo.fsPath);
600
});
601
602
it('shows empty folder options when no MRU entries', async () => {
603
const state = await getInputState();
604
const folderGroup = getGroup(state, 'folder');
605
606
expect(folderGroup).toBeDefined();
607
expect(folderGroup!.items).toHaveLength(0);
608
});
609
610
it('handler commits MRU fallback folder when no selection', async () => {
611
const mruFolder = URI.file('/recent/project');
612
mockFolderMruService.setMRUEntries([
613
{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },
614
]);
615
616
const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService);
617
expect(folderInfo.cwd).toBe(mruFolder.fsPath);
618
expect(folderInfo.additionalDirectories).toEqual([]);
619
});
620
621
it('handler commits home directory fallback when no folder available', async () => {
622
const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService);
623
expect(folderInfo.cwd).toBe(URI.file('/home/testuser').fsPath);
624
expect(folderInfo.additionalDirectories).toEqual([]);
625
});
626
627
it('handler commits selected folder over MRU', async () => {
628
const mruFolder = URI.file('/recent/project');
629
const selectedFolder = URI.file('/selected/project');
630
mockFolderMruService.setMRUEntries([
631
{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },
632
]);
633
634
seedSessionItem('test-session');
635
636
const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService, {
637
folderPath: selectedFolder.fsPath,
638
});
639
expect(folderInfo.cwd).toBe(selectedFolder.fsPath);
640
});
641
});
642
643
// #endregion
644
645
// #endregion
646
647
// #region Initial Session Options
648
649
describe('initial session options on new sessions', () => {
650
let mockAgentManager: ClaudeAgentManager;
651
let handlerProvider: ClaudeChatSessionContentProvider;
652
let handlerAccessor: ITestingServicesAccessor;
653
654
function createChatContext(sessionId: string, options?: { permissionMode?: string }): vscode.ChatContext {
655
return {
656
history: [],
657
yieldRequested: false,
658
chatSessionContext: {
659
isUntitled: false,
660
chatSessionItem: {
661
resource: ClaudeSessionUri.forSessionId(sessionId),
662
label: 'Test Session',
663
},
664
inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
665
},
666
} as vscode.ChatContext;
667
}
668
669
beforeEach(() => {
670
const mocks = createDefaultMocks();
671
mockSessionService = mocks.mockSessionService;
672
673
mockFolderMruService = mocks.mockFolderMruService;
674
mockAgentManager = createMockAgentManager();
675
676
const result = createProviderWithServices(store, [workspaceFolderUri], mocks, mockAgentManager);
677
handlerProvider = result.provider;
678
handlerAccessor = result.accessor;
679
});
680
681
it('sets permission mode from inputState on new session', async () => {
682
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
683
684
seedSessionItem('new-session-1');
685
686
const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);
687
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
688
689
const handler = handlerProvider.createHandler();
690
const context = createChatContext('new-session-1', { permissionMode: 'plan' });
691
const stream = new MockChatResponseStream();
692
693
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
694
695
expect(setPermissionSpy).toHaveBeenCalledWith('new-session-1', 'plan');
696
});
697
698
it('defaults to acceptEdits when inputState has default permission mode', async () => {
699
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
700
701
seedSessionItem('new-session-2');
702
703
const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);
704
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
705
706
const handler = handlerProvider.createHandler();
707
const context = createChatContext('new-session-2');
708
const stream = new MockChatResponseStream();
709
710
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
711
712
expect(setPermissionSpy).toHaveBeenCalledWith('new-session-2', 'acceptEdits');
713
});
714
715
it('commits the inputState permission mode to session state service', async () => {
716
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
717
718
seedSessionItem('pre-set-session');
719
720
const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);
721
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
722
723
const handler = handlerProvider.createHandler();
724
const context = createChatContext('pre-set-session', { permissionMode: 'default' });
725
const stream = new MockChatResponseStream();
726
727
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
728
729
expect(setPermissionSpy).toHaveBeenCalledWith('pre-set-session', 'default');
730
});
731
732
it('commits inputState permission mode on resumed sessions', async () => {
733
vi.mocked(mockSessionService.getSession).mockResolvedValue({
734
id: 'existing-session',
735
messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],
736
subagents: [],
737
} as any);
738
739
seedSessionItem('existing-session');
740
741
const sessionStateService = handlerAccessor.get(IClaudeSessionStateService);
742
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
743
744
const handler = handlerProvider.createHandler();
745
const context = createChatContext('existing-session');
746
const stream = new MockChatResponseStream();
747
748
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
749
750
const committedMode = setPermissionSpy.mock.calls.find(c => c[0] === 'existing-session')?.[1];
751
expect(committedMode).toBe('acceptEdits');
752
});
753
});
754
755
describe('initial folder option on new sessions', () => {
756
const folderA = URI.file('/project-a');
757
const folderB = URI.file('/project-b');
758
let mockAgentManager: ClaudeAgentManager;
759
let multiRootProvider: ClaudeChatSessionContentProvider;
760
let multiRootAccessor: ITestingServicesAccessor;
761
762
function createChatContext(sessionId: string, options?: { folderPath?: string }): vscode.ChatContext {
763
return {
764
history: [],
765
yieldRequested: false,
766
chatSessionContext: {
767
isUntitled: false,
768
chatSessionItem: {
769
resource: ClaudeSessionUri.forSessionId(sessionId),
770
label: 'Test Session',
771
},
772
inputState: {
773
groups: buildInputStateGroups({
774
folderPath: options?.folderPath,
775
allFolderPaths: [folderA.fsPath, folderB.fsPath],
776
}),
777
sessionResource: undefined,
778
onDidChange: Event.None,
779
onDidDispose: Event.None,
780
},
781
},
782
} as vscode.ChatContext;
783
}
784
785
beforeEach(() => {
786
const mocks = createDefaultMocks();
787
mockSessionService = mocks.mockSessionService;
788
mockAgentManager = createMockAgentManager();
789
790
const result = createProviderWithServices(store, [folderA, folderB], mocks, mockAgentManager);
791
multiRootProvider = result.provider;
792
multiRootAccessor = result.accessor;
793
});
794
795
it('sets folder from inputState on new session', async () => {
796
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
797
798
seedSessionItem('new-folder-session');
799
800
const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService);
801
const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');
802
803
const handler = multiRootProvider.createHandler();
804
const context = createChatContext('new-folder-session', { folderPath: folderB.fsPath });
805
const stream = new MockChatResponseStream();
806
807
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
808
809
const folderInfo = setFolderInfoSpy.mock.calls.find(c => c[0] === 'new-folder-session')?.[1];
810
expect(folderInfo?.cwd).toBe(folderB.fsPath);
811
});
812
813
it('commits inputState folder selection to session state service', async () => {
814
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
815
816
seedSessionItem('pre-folder-session');
817
818
const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService);
819
const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');
820
821
const handler = multiRootProvider.createHandler();
822
const context = createChatContext('pre-folder-session', { folderPath: folderA.fsPath });
823
const stream = new MockChatResponseStream();
824
825
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
826
827
const folderInfo = setFolderInfoSpy.mock.calls.find(c => c[0] === 'pre-folder-session')?.[1];
828
expect(folderInfo?.cwd).toBe(folderA.fsPath);
829
});
830
});
831
832
// #endregion
833
834
// #region isNewSession Handling
835
836
describe('isNewSession determination via session service', () => {
837
let mockAgentManager: ClaudeAgentManager;
838
let handlerProvider: ClaudeChatSessionContentProvider;
839
840
function createChatContext(sessionId: string): vscode.ChatContext {
841
return {
842
history: [],
843
yieldRequested: false,
844
chatSessionContext: {
845
isUntitled: false,
846
chatSessionItem: {
847
resource: ClaudeSessionUri.forSessionId(sessionId),
848
label: 'Test Session',
849
},
850
inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
851
},
852
} as vscode.ChatContext;
853
}
854
855
beforeEach(() => {
856
const mocks = createDefaultMocks();
857
mockSessionService = mocks.mockSessionService;
858
859
mockFolderMruService = mocks.mockFolderMruService;
860
mockAgentManager = createMockAgentManager();
861
862
const result = createProviderWithServices(store, [workspaceFolderUri], mocks, mockAgentManager);
863
handlerProvider = result.provider;
864
});
865
866
it('treats session as new when no session exists on disk', async () => {
867
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
868
seedSessionItem('real-uuid-123');
869
870
const handler = handlerProvider.createHandler();
871
const context = createChatContext('real-uuid-123');
872
const stream = new MockChatResponseStream();
873
874
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
875
876
const handleRequestMock = vi.mocked(mockAgentManager.handleRequest);
877
expect(handleRequestMock).toHaveBeenCalledOnce();
878
879
const [sessionId, , , , isNewSession] = handleRequestMock.mock.calls[0];
880
expect(sessionId).toBe('real-uuid-123');
881
expect(isNewSession).toBe(true);
882
});
883
884
it('treats session as resumed when session exists on disk', async () => {
885
seedSessionItem('real-uuid-123');
886
vi.mocked(mockSessionService.getSession).mockResolvedValue({
887
id: 'real-uuid-123',
888
messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],
889
subagents: [],
890
} as any);
891
892
const handler = handlerProvider.createHandler();
893
const context = createChatContext('real-uuid-123');
894
const stream = new MockChatResponseStream();
895
896
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
897
898
const handleRequestMock = vi.mocked(mockAgentManager.handleRequest);
899
expect(handleRequestMock).toHaveBeenCalledOnce();
900
901
const [sessionId, , , , isNewSession] = handleRequestMock.mock.calls[0];
902
expect(sessionId).toBe('real-uuid-123');
903
expect(isNewSession).toBe(false);
904
});
905
906
it('second request is not treated as new when session exists on disk', async () => {
907
seedSessionItem('real-uuid-123');
908
const handler = handlerProvider.createHandler();
909
const stream = new MockChatResponseStream();
910
911
// First request: no session on disk yet → new session
912
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
913
const firstContext = createChatContext('real-uuid-123');
914
await handler(createTestRequest('first'), firstContext, stream, CancellationToken.None);
915
916
// Second request: session now exists on disk → resumed
917
vi.mocked(mockSessionService.getSession).mockResolvedValue({
918
id: 'real-uuid-123',
919
messages: [{ type: 'user', message: { role: 'user', content: 'first' } }],
920
subagents: [],
921
} as any);
922
const secondContext = createChatContext('real-uuid-123');
923
await handler(createTestRequest('second'), secondContext, stream, CancellationToken.None);
924
925
const handleRequestMock = vi.mocked(mockAgentManager.handleRequest);
926
const [, , , , secondIsNew] = handleRequestMock.mock.calls[1];
927
expect(secondIsNew).toBe(false);
928
});
929
});
930
931
// #endregion
932
933
// #region Handler Integration
934
935
describe('handler integration', () => {
936
let mockAgentManager: ClaudeAgentManager;
937
let handlerProvider: ClaudeChatSessionContentProvider;
938
let handlerAccessor: ITestingServicesAccessor;
939
940
function createChatContext(sessionId: string): vscode.ChatContext {
941
return {
942
history: [],
943
yieldRequested: false,
944
chatSessionContext: {
945
isUntitled: false,
946
chatSessionItem: {
947
resource: ClaudeSessionUri.forSessionId(sessionId),
948
label: 'Test Session',
949
},
950
inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
951
},
952
} as vscode.ChatContext;
953
}
954
955
beforeEach(() => {
956
const mocks = createDefaultMocks();
957
mockSessionService = mocks.mockSessionService;
958
959
mockFolderMruService = mocks.mockFolderMruService;
960
mockAgentManager = createMockAgentManager();
961
962
const result = createProviderWithServices(store, [workspaceFolderUri], mocks, mockAgentManager);
963
handlerProvider = result.provider;
964
handlerAccessor = result.accessor;
965
});
966
967
it('commits request.model.id to session state service', async () => {
968
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
969
seedSessionItem('session-1');
970
971
const handler = handlerProvider.createHandler();
972
const context = createChatContext('session-1');
973
const stream = new MockChatResponseStream();
974
975
const mockSessionStateService = handlerAccessor.get(IClaudeSessionStateService);
976
const setModelSpy = vi.spyOn(mockSessionStateService, 'setModelIdForSession');
977
978
await handler(createTestRequest('hello'), context, stream, CancellationToken.None);
979
980
expect(setModelSpy).toHaveBeenCalledWith('session-1', parseClaudeModelId('claude-3-5-sonnet-20241022'));
981
});
982
983
it('short-circuits before session resolution when slash command is handled', async () => {
984
const slashCommandService = handlerAccessor.get(IClaudeSlashCommandService);
985
vi.mocked(slashCommandService.tryHandleCommand).mockResolvedValue({
986
handled: true,
987
result: { metadata: { command: '/test' } },
988
} as any);
989
990
const handler = handlerProvider.createHandler();
991
const context = createChatContext('session-1');
992
const stream = new MockChatResponseStream();
993
994
const result = await handler(new TestChatRequest('/test'), context, stream, CancellationToken.None);
995
996
// Slash command handled → no agent call
997
expect(vi.mocked(mockAgentManager.handleRequest)).not.toHaveBeenCalled();
998
expect(result).toEqual({ metadata: { command: '/test' } });
999
});
1000
});
1001
1002
// #endregion
1003
1004
// #region Observable pipeline reactivity
1005
1006
/**
1007
* These tests drive the input-state observable pipeline end-to-end via the
1008
* external signals it observes (config change, workspace folder change,
1009
* session-state change, session start) and assert the resulting
1010
* `state.groups` reflect each event. This is the "series of events" testing
1011
* the observable refactor was designed to enable.
1012
*/
1013
describe('observable pipeline reactivity', () => {
1014
const folderA = URI.file('/project-a');
1015
const folderB = URI.file('/project-b');
1016
1017
async function flushMicrotasks(): Promise<void> {
1018
// Autoruns that schedule async work (e.g. MRU fetch when workspace goes empty)
1019
// settle on the microtask queue. Two ticks covers chained thenables.
1020
await Promise.resolve();
1021
await Promise.resolve();
1022
}
1023
1024
it('toggling bypass-permissions config adds/removes the bypass item reactively', async () => {
1025
const mocks = createDefaultMocks();
1026
const { accessor: localAccessor } = createProviderWithServices(store, [folderA, folderB], mocks);
1027
const configService = localAccessor.get(IConfigurationService);
1028
1029
const state = await getInputState();
1030
let permissionGroup = getGroup(state, 'permissionMode')!;
1031
expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions');
1032
1033
await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, true);
1034
permissionGroup = getGroup(state, 'permissionMode')!;
1035
expect(permissionGroup.items.map(i => i.id)).toContain('bypassPermissions');
1036
1037
await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, false);
1038
permissionGroup = getGroup(state, 'permissionMode')!;
1039
expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions');
1040
});
1041
1042
it('workspace folder changes reshape the folder group', async () => {
1043
const mocks = createDefaultMocks();
1044
const mutableWs = new MutableWorkspaceService([folderA, folderB]);
1045
createProviderWithServices(store, [], mocks, undefined, mutableWs);
1046
1047
const state = await getInputState();
1048
let folderGroup = getGroup(state, 'folder');
1049
expect(folderGroup).toBeDefined();
1050
expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
1051
1052
// Add a third folder
1053
const folderC = URI.file('/project-c');
1054
mutableWs.setFolders([folderA, folderB, folderC]);
1055
folderGroup = getGroup(state, 'folder');
1056
expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath, folderC.fsPath]);
1057
1058
// Transition to a single folder → group hides
1059
mutableWs.setFolders([folderA]);
1060
folderGroup = getGroup(state, 'folder');
1061
expect(folderGroup).toBeUndefined();
1062
1063
// Back to multi-root
1064
mutableWs.setFolders([folderA, folderB]);
1065
folderGroup = getGroup(state, 'folder');
1066
expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
1067
});
1068
1069
it('emptying the workspace falls back to MRU items', async () => {
1070
const mocks = createDefaultMocks();
1071
const mutableWs = new MutableWorkspaceService([folderA, folderB]);
1072
const mruFolder = URI.file('/recent/project');
1073
mocks.mockFolderMruService.setMRUEntries([
1074
{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },
1075
]);
1076
createProviderWithServices(store, [], mocks, undefined, mutableWs);
1077
1078
const state = await getInputState();
1079
mutableWs.setFolders([]);
1080
await flushMicrotasks();
1081
1082
const folderGroup = getGroup(state, 'folder');
1083
expect(folderGroup).toBeDefined();
1084
expect(folderGroup!.items.map(i => i.id)).toEqual([mruFolder.fsPath]);
1085
});
1086
1087
it('external session-state permission change syncs into the input state', async () => {
1088
const mocks = createDefaultMocks();
1089
const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);
1090
const sessionStateService = localAccessor.get(IClaudeSessionStateService);
1091
1092
// Mark as existing so the pipeline wires up the external permission autorun
1093
const existingSession = { id: 'external-session', messages: [], subagents: [] };
1094
vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any);
1095
1096
const sessionUri = createClaudeSessionUri('external-session');
1097
const state = await getInputState(sessionUri);
1098
expect(getGroup(state, 'permissionMode')!.selected?.id).not.toBe('plan');
1099
1100
sessionStateService.setPermissionModeForSession('external-session', 'plan');
1101
expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('plan');
1102
1103
sessionStateService.setPermissionModeForSession('external-session', 'default');
1104
expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default');
1105
});
1106
1107
it('live permission option changes update session state', async () => {
1108
const mocks = createDefaultMocks();
1109
const { provider, accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);
1110
const sessionStateService = localAccessor.get(IClaudeSessionStateService);
1111
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
1112
1113
provider.provideHandleOptionsChange(createClaudeSessionUri('live-session'), [
1114
{ optionId: 'permissionMode', value: 'plan' }
1115
], CancellationToken.None);
1116
1117
expect(setPermissionSpy).toHaveBeenCalledWith('live-session', 'plan');
1118
expect(sessionStateService.getPermissionModeForSession('live-session')).toBe('plan');
1119
});
1120
1121
it('external permission change syncs into a previousInputState-restored pipeline', async () => {
1122
const mocks = createDefaultMocks();
1123
const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);
1124
const sessionStateService = localAccessor.get(IClaudeSessionStateService);
1125
1126
const existingSession = { id: 'prev-state-session', messages: [], subagents: [] };
1127
vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any);
1128
1129
const sessionUri = createClaudeSessionUri('prev-state-session');
1130
const firstState = await getInputState(sessionUri);
1131
1132
// Simulate getChatSessionInputState being called again with previousInputState
1133
// (e.g. user refocuses the chat window). The pipeline is rebuilt from scratch.
1134
const restoredState = await getInputState(sessionUri, firstState);
1135
expect(getGroup(restoredState, 'permissionMode')!.selected?.id).not.toBe('plan');
1136
1137
// Permission mode changes externally (e.g. EnterPlanMode tool call)
1138
sessionStateService.setPermissionModeForSession('prev-state-session', 'plan');
1139
expect(getGroup(restoredState, 'permissionMode')!.selected?.id).toBe('plan');
1140
1141
sessionStateService.setPermissionModeForSession('prev-state-session', 'acceptEdits');
1142
expect(getGroup(restoredState, 'permissionMode')!.selected?.id).toBe('acceptEdits');
1143
});
1144
1145
it('sessionResource locks the folder group for existing sessions', async () => {
1146
const mocks = createDefaultMocks();
1147
createProviderWithServices(store, [folderA, folderB], mocks);
1148
1149
// New session (no sessionResource) — folder is unlocked
1150
const newState = await getInputState();
1151
let folderGroup = getGroup(newState, 'folder')!;
1152
expect(folderGroup.items.every(i => !i.locked)).toBe(true);
1153
expect(folderGroup.selected?.locked).toBeUndefined();
1154
1155
// Existing session (sessionResource provided) — folder is locked
1156
vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue({
1157
id: 'started-session',
1158
messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }],
1159
subagents: [],
1160
} as any);
1161
const sessionUri = createClaudeSessionUri('started-session');
1162
const startedState = await getInputState(sessionUri);
1163
1164
folderGroup = getGroup(startedState, 'folder')!;
1165
expect(folderGroup.items.every(i => i.locked === true)).toBe(true);
1166
expect(folderGroup.selected?.locked).toBe(true);
1167
});
1168
1169
it('restoring a locked previousInputState preserves the lock across workspace changes', async () => {
1170
const mocks = createDefaultMocks();
1171
const mutableWs = new MutableWorkspaceService([folderA, folderB]);
1172
createProviderWithServices(store, [], mocks, undefined, mutableWs);
1173
1174
// First state — mark it as started to get locked items
1175
const initialState = await getInputState();
1176
const initialGroup = getGroup(initialState, 'folder')!;
1177
// Synthesize a locked previousInputState (matching what a started session looks like)
1178
const lockedGroups: vscode.ChatSessionProviderOptionGroup[] = initialState.groups.map(g =>
1179
g.id === 'folder'
1180
? {
1181
...g,
1182
items: g.items.map(i => ({ ...i, locked: true })),
1183
selected: g.selected ? { ...g.selected, locked: true } : undefined,
1184
}
1185
: g
1186
);
1187
const lockedPrevious: vscode.ChatSessionInputState = {
1188
groups: lockedGroups,
1189
sessionResource: undefined,
1190
onDidChange: Event.None,
1191
onDidDispose: Event.None,
1192
};
1193
// sanity check
1194
expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
1195
1196
// Restore from the locked previous state
1197
const restoredState = await getInputState(undefined, lockedPrevious);
1198
let restoredGroup = getGroup(restoredState, 'folder')!;
1199
expect(restoredGroup.items.every(i => i.locked === true)).toBe(true);
1200
1201
// Now workspace folders change — lock must persist
1202
const folderC = URI.file('/project-c');
1203
mutableWs.setFolders([folderA, folderB, folderC]);
1204
restoredGroup = getGroup(restoredState, 'folder')!;
1205
expect(restoredGroup.items).toHaveLength(3);
1206
expect(restoredGroup.items.every(i => i.locked === true)).toBe(true);
1207
});
1208
});
1209
1210
// #endregion
1211
});
1212
1213
// #region FakeGitService
1214
1215
/**
1216
* A git service mock with event emitters that can be fired in tests.
1217
* Unlike MockGitService, this supports onDidOpenRepository event firing.
1218
*/
1219
class FakeGitService extends mock<IGitService>() {
1220
private readonly _onDidOpenRepository = new Emitter<RepoContext>();
1221
override readonly onDidOpenRepository = this._onDidOpenRepository.event;
1222
1223
private readonly _onDidCloseRepository = new Emitter<RepoContext>();
1224
override readonly onDidCloseRepository = this._onDidCloseRepository.event;
1225
1226
override readonly onDidFinishInitialization: Event<void> = Event.None;
1227
1228
override repositories: RepoContext[] = [];
1229
override isInitialized = true;
1230
1231
fireOpenRepository(repo: RepoContext): void {
1232
this._onDidOpenRepository.fire(repo);
1233
}
1234
1235
fireCloseRepository(repo: RepoContext): void {
1236
this._onDidCloseRepository.fire(repo);
1237
}
1238
1239
override dispose(): void {
1240
super.dispose();
1241
this._onDidOpenRepository.dispose();
1242
this._onDidCloseRepository.dispose();
1243
}
1244
}
1245
1246
// #endregion
1247
1248
// #region Test helpers
1249
1250
function buildRepoContext(overrides: {
1251
rootUri?: URI;
1252
headBranchName?: string;
1253
upstreamRemote?: string;
1254
upstreamBranchName?: string;
1255
headIncomingChanges?: number;
1256
headOutgoingChanges?: number;
1257
changes?: RepoContext['changes'];
1258
remoteFetchUrls?: Array<string | undefined>;
1259
} = {}): RepoContext {
1260
return {
1261
rootUri: overrides.rootUri ?? URI.file('/project'),
1262
kind: 'repository',
1263
isUsingVirtualFileSystem: false,
1264
headIncomingChanges: overrides.headIncomingChanges ?? 0,
1265
headOutgoingChanges: overrides.headOutgoingChanges ?? 0,
1266
headBranchName: overrides.headBranchName ?? 'main',
1267
headCommitHash: 'abc123',
1268
upstreamBranchName: overrides.upstreamBranchName,
1269
upstreamRemote: overrides.upstreamRemote,
1270
isRebasing: false,
1271
remoteFetchUrls: overrides.remoteFetchUrls ?? [],
1272
remotes: [],
1273
worktrees: [],
1274
changes: overrides.changes,
1275
headBranchNameObs: observableValue('test', overrides.headBranchName ?? 'main'),
1276
headCommitHashObs: observableValue('test', 'abc123'),
1277
upstreamBranchNameObs: observableValue('test', overrides.upstreamBranchName),
1278
upstreamRemoteObs: observableValue('test', overrides.upstreamRemote),
1279
isRebasingObs: observableValue('test', false),
1280
isIgnored: () => Promise.resolve(false),
1281
};
1282
}
1283
1284
const MockChange = mock<Change>();
1285
function mockChange(): Change {
1286
return new MockChange();
1287
}
1288
1289
function findCommandHandler(commandId: string): (...args: unknown[]) => Promise<void> {
1290
const calls = vi.mocked(vscodeShim.commands.registerCommand).mock.calls;
1291
const matchingCalls = calls.filter(c => c[0] === commandId);
1292
const call = matchingCalls[matchingCalls.length - 1];
1293
if (!call) {
1294
throw new Error(`Command ${commandId} was not registered`);
1295
}
1296
return call[1];
1297
}
1298
1299
function buildDiskSession(id: string, overrides: Partial<IClaudeCodeSessionInfo> = {}): IClaudeCodeSessionInfo {
1300
return {
1301
id,
1302
label: id,
1303
created: Date.now(),
1304
lastRequestEnded: Date.now(),
1305
folderName: 'my-project',
1306
cwd: '/home/user/my-project',
1307
...overrides,
1308
} as IClaudeCodeSessionInfo;
1309
}
1310
1311
// #endregion
1312
1313
describe('ClaudeChatSessionItemController', () => {
1314
const store = new DisposableStore();
1315
let mockSessionService: IClaudeCodeSessionService;
1316
let mockSdkService: IClaudeCodeSdkService;
1317
let controller: ClaudeChatSessionItemController;
1318
let lastControllerAccessor: ITestingServicesAccessor;
1319
1320
function getItem(sessionId: string): vscode.ChatSessionItem | undefined {
1321
return lastCreatedItemsMap.get(ClaudeSessionUri.forSessionId(sessionId).toString());
1322
}
1323
1324
function createController(workspaceFolders: URI[], gitService?: IGitService): ClaudeChatSessionItemController {
1325
const serviceCollection = store.add(createExtensionUnitTestingServices());
1326
const workspaceService = new TestWorkspaceService(workspaceFolders);
1327
serviceCollection.set(IWorkspaceService, workspaceService);
1328
serviceCollection.set(IGitService, gitService ?? new MockGitService());
1329
serviceCollection.define(IClaudeCodeSessionService, mockSessionService);
1330
serviceCollection.define(IChatFolderMruService, new MockChatFolderMruService());
1331
mockSdkService = {
1332
_serviceBrand: undefined,
1333
query: vi.fn(),
1334
listSessions: vi.fn().mockResolvedValue([]),
1335
getSessionInfo: vi.fn().mockResolvedValue(undefined),
1336
getSessionMessages: vi.fn().mockResolvedValue([]),
1337
renameSession: vi.fn().mockResolvedValue(undefined),
1338
forkSession: vi.fn().mockResolvedValue({ sessionId: 'forked-session-id' }),
1339
listSubagents: vi.fn().mockResolvedValue([]),
1340
getSubagentMessages: vi.fn().mockResolvedValue([]),
1341
};
1342
serviceCollection.define(IClaudeCodeSdkService, mockSdkService);
1343
serviceCollection.define(IClaudeWorkspaceFolderService, {
1344
_serviceBrand: undefined,
1345
getWorkspaceChanges: vi.fn().mockResolvedValue([]),
1346
});
1347
const accessor = serviceCollection.createTestingAccessor();
1348
lastControllerAccessor = accessor;
1349
const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController);
1350
store.add(ctrl);
1351
return ctrl;
1352
}
1353
1354
beforeEach(() => {
1355
mockSessionService = {
1356
_serviceBrand: undefined,
1357
getSession: vi.fn().mockResolvedValue(undefined),
1358
getAllSessions: vi.fn().mockResolvedValue([]),
1359
} as unknown as IClaudeCodeSessionService;
1360
});
1361
1362
afterEach(() => {
1363
vi.clearAllMocks();
1364
store.clear();
1365
});
1366
1367
// #region updateItemStatus
1368
1369
describe('updateItemStatus', () => {
1370
beforeEach(() => {
1371
controller = createController([URI.file('/project')]);
1372
});
1373
1374
it('creates a new item with the provided label when no disk session exists', async () => {
1375
await controller.updateItemStatus('new-session', ChatSessionStatus.InProgress, 'Hello world');
1376
1377
const item = getItem('new-session');
1378
expect(item).toBeDefined();
1379
expect(item!.label).toBe('Hello world');
1380
expect(item!.status).toBe(ChatSessionStatus.InProgress);
1381
});
1382
1383
it('sets timing.lastRequestStarted and clears lastRequestEnded for InProgress', async () => {
1384
const before = Date.now();
1385
await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');
1386
const after = Date.now();
1387
1388
const item = getItem('session-1');
1389
expect(item!.timing).toBeDefined();
1390
expect(item!.timing!.lastRequestStarted).toBeGreaterThanOrEqual(before);
1391
expect(item!.timing!.lastRequestStarted).toBeLessThanOrEqual(after);
1392
expect(item!.timing!.lastRequestEnded).toBeUndefined();
1393
});
1394
1395
it('sets timing.lastRequestEnded for Completed status', async () => {
1396
await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');
1397
1398
const beforeComplete = Date.now();
1399
await controller.updateItemStatus('session-1', ChatSessionStatus.Completed, 'Test prompt');
1400
const afterComplete = Date.now();
1401
1402
const item = getItem('session-1');
1403
expect(item!.timing!.lastRequestEnded).toBeGreaterThanOrEqual(beforeComplete);
1404
expect(item!.timing!.lastRequestEnded).toBeLessThanOrEqual(afterComplete);
1405
});
1406
1407
it('clears lastRequestEnded on second InProgress after Completed', async () => {
1408
await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');
1409
await controller.updateItemStatus('session-1', ChatSessionStatus.Completed, 'Test prompt');
1410
await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'Test prompt');
1411
1412
const item = getItem('session-1');
1413
expect(item!.timing!.lastRequestEnded).toBeUndefined();
1414
expect(item!.timing!.lastRequestStarted).toBeDefined();
1415
});
1416
1417
it('creates timing with lastRequestEnded when Completed is called without prior InProgress', async () => {
1418
const before = Date.now();
1419
await controller.updateItemStatus('session-1', ChatSessionStatus.Completed, 'Test prompt');
1420
const after = Date.now();
1421
1422
const item = getItem('session-1');
1423
expect(item!.timing).toBeDefined();
1424
expect(item!.timing!.created).toBeGreaterThanOrEqual(before);
1425
expect(item!.timing!.created).toBeLessThanOrEqual(after);
1426
expect(item!.timing!.lastRequestEnded).toBeGreaterThanOrEqual(before);
1427
expect(item!.timing!.lastRequestEnded).toBeLessThanOrEqual(after);
1428
});
1429
1430
it('uses session data from disk when available', async () => {
1431
const diskSession: IClaudeCodeSessionInfo = {
1432
id: 'disk-session',
1433
label: 'Disk Session Label',
1434
created: new Date('2024-01-01T00:00:00Z').getTime(),
1435
lastRequestEnded: new Date('2024-01-01T01:00:00Z').getTime(),
1436
folderName: 'my-project',
1437
};
1438
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1439
1440
await controller.updateItemStatus('disk-session', ChatSessionStatus.InProgress, 'Ignored label');
1441
1442
const item = getItem('disk-session');
1443
expect(item).toBeDefined();
1444
expect(item!.label).toBe('Disk Session Label');
1445
expect(item!.tooltip).toBe('Claude Code session: Disk Session Label');
1446
1447
expect(mockSessionService.getSession).toHaveBeenCalledOnce();
1448
const [calledUri] = vi.mocked(mockSessionService.getSession).mock.calls[0];
1449
expect(calledUri.scheme).toBe('claude-code');
1450
expect(calledUri.path).toBe('/disk-session');
1451
});
1452
1453
it('handles multiple independent sessions', async () => {
1454
await controller.updateItemStatus('session-a', ChatSessionStatus.InProgress, 'Prompt A');
1455
await controller.updateItemStatus('session-b', ChatSessionStatus.InProgress, 'Prompt B');
1456
await controller.updateItemStatus('session-a', ChatSessionStatus.Completed, 'Prompt A');
1457
1458
const itemA = getItem('session-a');
1459
const itemB = getItem('session-b');
1460
expect(itemA!.status).toBe(ChatSessionStatus.Completed);
1461
expect(itemB!.status).toBe(ChatSessionStatus.InProgress);
1462
});
1463
1464
it('calls getWorkspaceChanges on Completed status when session has cwd', async () => {
1465
const diskSession: IClaudeCodeSessionInfo = {
1466
id: 'changes-session',
1467
label: 'Changes Session',
1468
created: Date.now(),
1469
lastRequestEnded: Date.now(),
1470
folderName: 'my-project',
1471
cwd: '/home/user/my-project',
1472
gitBranch: 'feature-branch',
1473
};
1474
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1475
1476
const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }];
1477
const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);
1478
vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any);
1479
1480
await controller.updateItemStatus('changes-session', ChatSessionStatus.InProgress, 'Prompt');
1481
await controller.updateItemStatus('changes-session', ChatSessionStatus.Completed, 'Prompt');
1482
1483
expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith(
1484
'/home/user/my-project',
1485
'feature-branch',
1486
undefined,
1487
true,
1488
);
1489
const item = getItem('changes-session');
1490
expect(item!.changes).toBe(mockChanges);
1491
});
1492
1493
it('does not call getWorkspaceChanges on Completed when session has no cwd', async () => {
1494
const diskSession: IClaudeCodeSessionInfo = {
1495
id: 'no-cwd',
1496
label: 'No CWD',
1497
created: Date.now(),
1498
lastRequestEnded: Date.now(),
1499
folderName: undefined,
1500
};
1501
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1502
1503
const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);
1504
1505
await controller.updateItemStatus('no-cwd', ChatSessionStatus.InProgress, 'Prompt');
1506
await controller.updateItemStatus('no-cwd', ChatSessionStatus.Completed, 'Prompt');
1507
1508
expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalled();
1509
});
1510
1511
it('does not call getWorkspaceChanges with forceRefresh on InProgress status', async () => {
1512
const diskSession: IClaudeCodeSessionInfo = {
1513
id: 'in-progress',
1514
label: 'In Progress',
1515
created: Date.now(),
1516
lastRequestEnded: Date.now(),
1517
folderName: 'my-project',
1518
cwd: '/home/user/my-project',
1519
gitBranch: 'feature-branch',
1520
};
1521
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1522
1523
const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);
1524
1525
await controller.updateItemStatus('in-progress', ChatSessionStatus.InProgress, 'Prompt');
1526
1527
expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalledWith(
1528
expect.anything(),
1529
expect.anything(),
1530
expect.anything(),
1531
true,
1532
);
1533
});
1534
});
1535
1536
// #endregion
1537
1538
// #region Session item properties
1539
1540
describe('session item properties', () => {
1541
beforeEach(() => {
1542
controller = createController([URI.file('/project')]);
1543
});
1544
1545
it('sets resource with correct scheme and path', async () => {
1546
await controller.updateItemStatus('my-session', ChatSessionStatus.InProgress, 'hello');
1547
1548
const item = getItem('my-session');
1549
expect(item!.resource.scheme).toBe('claude-code');
1550
expect(item!.resource.path).toBe('/my-session');
1551
});
1552
1553
it('sets tooltip to formatted session name', async () => {
1554
await controller.updateItemStatus('my-session', ChatSessionStatus.InProgress, 'fix the bug');
1555
1556
const item = getItem('my-session');
1557
expect(item!.tooltip).toBe('Claude Code session: fix the bug');
1558
});
1559
1560
it('sets iconPath to claude ThemeIcon', async () => {
1561
await controller.updateItemStatus('my-session', ChatSessionStatus.InProgress, 'hello');
1562
1563
const item = getItem('my-session');
1564
expect(item!.iconPath).toBeDefined();
1565
expect(item!.iconPath).toBeInstanceOf(ThemeIcon);
1566
expect((item!.iconPath as ThemeIcon).id).toBe('claude');
1567
});
1568
1569
it('uses disk session label and timestamps when available', async () => {
1570
const diskSession: IClaudeCodeSessionInfo = {
1571
id: 'disk-session',
1572
label: 'Disk Label',
1573
created: new Date('2024-06-01T12:00:00Z').getTime(),
1574
lastRequestEnded: new Date('2024-06-01T13:00:00Z').getTime(),
1575
folderName: undefined,
1576
};
1577
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1578
1579
await controller.updateItemStatus('disk-session', ChatSessionStatus.InProgress, 'Prompt');
1580
1581
const item = getItem('disk-session');
1582
expect(item!.label).toBe('Disk Label');
1583
expect(item!.tooltip).toBe('Claude Code session: Disk Label');
1584
// timing.created is derived from created
1585
expect(item!.timing!.created).toBe(new Date('2024-06-01T12:00:00Z').getTime());
1586
});
1587
1588
it('sets metadata with workingDirectoryPath when session has cwd', async () => {
1589
const diskSession: IClaudeCodeSessionInfo = {
1590
id: 'cwd-session',
1591
label: 'CWD Session',
1592
created: Date.now(),
1593
lastRequestEnded: Date.now(),
1594
folderName: 'my-project',
1595
cwd: '/home/user/my-project',
1596
};
1597
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1598
1599
await controller.updateItemStatus('cwd-session', ChatSessionStatus.InProgress, 'Prompt');
1600
1601
const item = getItem('cwd-session');
1602
expect(item!.metadata).toEqual({ workingDirectoryPath: '/home/user/my-project' });
1603
});
1604
1605
it('does not set metadata when session has no cwd', async () => {
1606
await controller.updateItemStatus('no-cwd-session', ChatSessionStatus.InProgress, 'Prompt');
1607
1608
const item = getItem('no-cwd-session');
1609
expect(item!.metadata).toBeUndefined();
1610
});
1611
1612
it('populates item.changes when session has cwd and gitBranch', async () => {
1613
const diskSession: IClaudeCodeSessionInfo = {
1614
id: 'changes-item',
1615
label: 'Changes Item',
1616
created: Date.now(),
1617
lastRequestEnded: Date.now(),
1618
folderName: 'my-project',
1619
cwd: '/home/user/my-project',
1620
gitBranch: 'feature-branch',
1621
};
1622
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
1623
1624
const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }];
1625
const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService);
1626
vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any);
1627
1628
await controller.updateItemStatus('changes-item', ChatSessionStatus.InProgress, 'Prompt');
1629
1630
expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith(
1631
'/home/user/my-project',
1632
'feature-branch',
1633
undefined,
1634
);
1635
const item = getItem('changes-item');
1636
expect(item!.changes).toBe(mockChanges);
1637
});
1638
});
1639
1640
// #endregion
1641
1642
// #region Badge visibility
1643
1644
describe('badge visibility', () => {
1645
it('does not show badge in single-root workspace with zero repos', async () => {
1646
controller = createController([URI.file('/project')]);
1647
1648
const sessionInfo: IClaudeCodeSessionInfo = {
1649
id: 'test',
1650
label: 'Test',
1651
created: Date.now(),
1652
lastRequestEnded: Date.now(),
1653
folderName: 'project',
1654
};
1655
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1656
1657
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1658
1659
const item = getItem('test');
1660
expect(item!.badge).toBeUndefined();
1661
});
1662
1663
it('shows badge in multi-root workspace', async () => {
1664
controller = createController([URI.file('/project-a'), URI.file('/project-b')]);
1665
1666
const sessionInfo: IClaudeCodeSessionInfo = {
1667
id: 'test',
1668
label: 'Test',
1669
created: Date.now(),
1670
lastRequestEnded: Date.now(),
1671
folderName: 'project-a',
1672
};
1673
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1674
1675
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1676
1677
const item = getItem('test');
1678
expect(item!.badge).toBeDefined();
1679
expect(item!.badge).toBeInstanceOf(MarkdownString);
1680
expect((item!.badge as MarkdownString).value).toBe('$(folder) project-a');
1681
});
1682
1683
it('shows badge in empty workspace', async () => {
1684
controller = createController([]);
1685
1686
const sessionInfo: IClaudeCodeSessionInfo = {
1687
id: 'test',
1688
label: 'Test',
1689
created: Date.now(),
1690
lastRequestEnded: Date.now(),
1691
folderName: 'my-folder',
1692
};
1693
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1694
1695
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1696
1697
const item = getItem('test');
1698
expect(item!.badge).toBeDefined();
1699
expect((item!.badge as MarkdownString).value).toBe('$(folder) my-folder');
1700
});
1701
1702
it('badge has supportThemeIcons set to true', async () => {
1703
controller = createController([URI.file('/a'), URI.file('/b')]);
1704
1705
const sessionInfo: IClaudeCodeSessionInfo = {
1706
id: 'test',
1707
label: 'Test',
1708
created: Date.now(),
1709
lastRequestEnded: Date.now(),
1710
folderName: 'project',
1711
};
1712
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1713
1714
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1715
1716
const item = getItem('test');
1717
expect((item!.badge as MarkdownString).supportThemeIcons).toBe(true);
1718
});
1719
1720
it('badge is undefined when session has no folderName', async () => {
1721
controller = createController([URI.file('/a'), URI.file('/b')]);
1722
1723
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1724
1725
const item = getItem('test');
1726
// No disk session → no folderName → no badge even though multi-root
1727
expect(item!.badge).toBeUndefined();
1728
});
1729
1730
it('different sessions show their own folder names', async () => {
1731
controller = createController([URI.file('/a'), URI.file('/b')]);
1732
1733
vi.mocked(mockSessionService.getSession)
1734
.mockResolvedValueOnce({
1735
id: 'session-1', label: 'S1',
1736
created: Date.now(), lastRequestEnded: Date.now(),
1737
folderName: 'frontend',
1738
} as any)
1739
.mockResolvedValueOnce({
1740
id: 'session-2', label: 'S2',
1741
created: Date.now(), lastRequestEnded: Date.now(),
1742
folderName: 'backend',
1743
} as any);
1744
1745
await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'S1');
1746
await controller.updateItemStatus('session-2', ChatSessionStatus.InProgress, 'S2');
1747
1748
expect((getItem('session-1')!.badge as MarkdownString).value).toBe('$(folder) frontend');
1749
expect((getItem('session-2')!.badge as MarkdownString).value).toBe('$(folder) backend');
1750
});
1751
1752
it('shows badge in single-root workspace with multiple non-worktree repos', async () => {
1753
const fakeGit = new FakeGitService();
1754
fakeGit.repositories = [
1755
{ rootUri: URI.file('/project/repo1'), kind: 'repository' } as unknown as RepoContext,
1756
{ rootUri: URI.file('/project/repo2'), kind: 'repository' } as unknown as RepoContext,
1757
];
1758
controller = createController([URI.file('/project')], fakeGit);
1759
1760
const sessionInfo: IClaudeCodeSessionInfo = {
1761
id: 'test', label: 'Test',
1762
created: Date.now(), lastRequestEnded: Date.now(),
1763
folderName: 'repo1',
1764
};
1765
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1766
1767
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1768
1769
const item = getItem('test');
1770
expect(item!.badge).toBeDefined();
1771
expect((item!.badge as MarkdownString).value).toBe('$(folder) repo1');
1772
});
1773
1774
it('does not show badge when extra repos are worktrees', async () => {
1775
const fakeGit = new FakeGitService();
1776
fakeGit.repositories = [
1777
{ rootUri: URI.file('/project/main'), kind: 'repository' } as unknown as RepoContext,
1778
{ rootUri: URI.file('/project/wt'), kind: 'worktree' } as unknown as RepoContext,
1779
];
1780
controller = createController([URI.file('/project')], fakeGit);
1781
1782
const sessionInfo: IClaudeCodeSessionInfo = {
1783
id: 'test', label: 'Test',
1784
created: Date.now(), lastRequestEnded: Date.now(),
1785
folderName: 'main',
1786
};
1787
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1788
1789
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1790
1791
const item = getItem('test');
1792
// Only 1 non-worktree repo → no badge
1793
expect(item!.badge).toBeUndefined();
1794
});
1795
});
1796
1797
// #endregion
1798
1799
// #region Git event refresh
1800
1801
describe('git event refresh', () => {
1802
it('recomputes badge when a repository opens', async () => {
1803
const fakeGit = new FakeGitService();
1804
fakeGit.repositories = [];
1805
controller = createController([URI.file('/project')], fakeGit);
1806
1807
const sessionInfo: IClaudeCodeSessionInfo = {
1808
id: 'test', label: 'Test',
1809
created: Date.now(), lastRequestEnded: Date.now(),
1810
folderName: 'repo1',
1811
};
1812
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1813
vi.mocked(mockSessionService.getAllSessions).mockResolvedValue([sessionInfo]);
1814
1815
// Initially no repos → single-root with 0 repos, _computeShowBadge returns false
1816
await controller.updateItemStatus('test', ChatSessionStatus.Completed, 'hello');
1817
expect(getItem('test')!.badge).toBeUndefined();
1818
1819
// Now simulate two repos opening (monorepo scenario)
1820
const repo1 = { rootUri: URI.file('/project/r1'), kind: 'repository' } as unknown as RepoContext;
1821
const repo2 = { rootUri: URI.file('/project/r2'), kind: 'repository' } as unknown as RepoContext;
1822
fakeGit.repositories = [repo1, repo2];
1823
fakeGit.fireOpenRepository(repo2);
1824
1825
// Flush microtask queue so the async _refreshItems completes.
1826
await new Promise(r => setTimeout(r, 0));
1827
1828
const refreshedItem = getItem('test');
1829
expect(refreshedItem).toBeDefined();
1830
expect(refreshedItem!.badge).toBeDefined();
1831
expect((refreshedItem!.badge as MarkdownString).value).toBe('$(folder) repo1');
1832
});
1833
1834
it('recomputes badge when a repository closes', async () => {
1835
const fakeGit = new FakeGitService();
1836
const repo1 = { rootUri: URI.file('/project/r1'), kind: 'repository' } as unknown as RepoContext;
1837
const repo2 = { rootUri: URI.file('/project/r2'), kind: 'repository' } as unknown as RepoContext;
1838
fakeGit.repositories = [repo1, repo2];
1839
controller = createController([URI.file('/project')], fakeGit);
1840
1841
const sessionInfo: IClaudeCodeSessionInfo = {
1842
id: 'test', label: 'Test',
1843
created: Date.now(), lastRequestEnded: Date.now(),
1844
folderName: 'repo1',
1845
};
1846
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1847
vi.mocked(mockSessionService.getAllSessions).mockResolvedValue([sessionInfo]);
1848
1849
await controller.updateItemStatus('test', ChatSessionStatus.Completed, 'hello');
1850
expect(getItem('test')!.badge).toBeDefined();
1851
1852
// Close one repo → single non-worktree repo → badge should disappear
1853
fakeGit.repositories = [repo1];
1854
fakeGit.fireCloseRepository(repo2);
1855
1856
// Flush microtask queue so the async _refreshItems completes.
1857
await new Promise(r => setTimeout(r, 0));
1858
1859
const refreshedItem = getItem('test');
1860
expect(refreshedItem).toBeDefined();
1861
expect(refreshedItem!.badge).toBeUndefined();
1862
});
1863
1864
it('preserves in-progress items after refresh', async () => {
1865
const fakeGit = new FakeGitService();
1866
fakeGit.repositories = [];
1867
controller = createController([URI.file('/project')], fakeGit);
1868
1869
const sessionInfo: IClaudeCodeSessionInfo = {
1870
id: 'test', label: 'Test',
1871
created: Date.now(), lastRequestEnded: Date.now(),
1872
folderName: 'repo1',
1873
};
1874
vi.mocked(mockSessionService.getSession).mockResolvedValue(sessionInfo as any);
1875
vi.mocked(mockSessionService.getAllSessions).mockResolvedValue([sessionInfo]);
1876
1877
await controller.updateItemStatus('test', ChatSessionStatus.InProgress, 'hello');
1878
const itemBeforeRefresh = getItem('test');
1879
expect(itemBeforeRefresh).toBeDefined();
1880
expect(itemBeforeRefresh!.status).toBe(ChatSessionStatus.InProgress);
1881
1882
// Trigger a refresh via git event
1883
const repo1 = { rootUri: URI.file('/project/r1'), kind: 'repository' } as unknown as RepoContext;
1884
fakeGit.repositories = [repo1];
1885
fakeGit.fireOpenRepository(repo1);
1886
1887
await new Promise(r => setTimeout(r, 0));
1888
1889
const refreshedItem = getItem('test');
1890
expect(refreshedItem).toBeDefined();
1891
expect(refreshedItem!.status).toBe(ChatSessionStatus.InProgress);
1892
});
1893
});
1894
1895
// #endregion
1896
1897
// #endregion
1898
1899
// #region forkHandler
1900
1901
describe('forkHandler', () => {
1902
beforeEach(() => {
1903
controller = createController([URI.file('/project')]);
1904
});
1905
1906
function makeSession(id: string, messages: Array<{ uuid: string; type: string }>) {
1907
return {
1908
id,
1909
label: 'Test session',
1910
created: Date.now(),
1911
lastRequestEnded: Date.now(),
1912
messages: messages.map(m => ({
1913
...m,
1914
sessionId: id,
1915
timestamp: new Date(),
1916
parentUuid: null,
1917
message: {},
1918
})),
1919
subagents: [],
1920
};
1921
}
1922
1923
it('forks whole history when no request is specified', async () => {
1924
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
1925
lastCreatedItemsMap.set(sessionResource.toString(), {
1926
resource: sessionResource,
1927
label: 'Original',
1928
});
1929
1930
const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None);
1931
1932
expect(mockSdkService.forkSession).toHaveBeenCalledWith('sess-1', { upToMessageId: undefined, title: expect.any(String) });
1933
expect(result.resource.toString()).toContain('forked-session-id');
1934
expect(result.label).toContain('Forked');
1935
});
1936
1937
it('copies session state from parent to forked session', async () => {
1938
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
1939
lastCreatedItemsMap.set(sessionResource.toString(), {
1940
resource: sessionResource,
1941
label: 'Original',
1942
});
1943
1944
// Seed the parent session with non-default state
1945
const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);
1946
sessionStateService.setPermissionModeForSession('sess-1', 'plan');
1947
sessionStateService.setFolderInfoForSession('sess-1', {
1948
cwd: '/custom/folder',
1949
additionalDirectories: ['/extra'],
1950
});
1951
1952
const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
1953
const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');
1954
1955
await lastForkHandler!(sessionResource, undefined, CancellationToken.None);
1956
1957
expect(setPermissionSpy).toHaveBeenCalledWith('forked-session-id', 'plan');
1958
expect(setFolderInfoSpy).toHaveBeenCalledWith('forked-session-id', {
1959
cwd: '/custom/folder',
1960
additionalDirectories: ['/extra'],
1961
});
1962
});
1963
1964
it('forks at the message before the specified request', async () => {
1965
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
1966
lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });
1967
1968
const session = makeSession('sess-1', [
1969
{ uuid: 'msg-1', type: 'user' },
1970
{ uuid: 'msg-2', type: 'assistant' },
1971
{ uuid: 'msg-3', type: 'user' },
1972
]);
1973
vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);
1974
1975
const request = { id: 'msg-3', prompt: 'test' } as vscode.ChatRequestTurn2;
1976
await lastForkHandler!(sessionResource, request, CancellationToken.None);
1977
1978
expect(mockSdkService.forkSession).toHaveBeenCalledWith('sess-1', { upToMessageId: 'msg-2', title: expect.any(String) });
1979
});
1980
1981
it('throws when session is not found for a specific request fork', async () => {
1982
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
1983
lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });
1984
vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined);
1985
1986
const request = { id: 'msg-1', prompt: 'test' } as vscode.ChatRequestTurn2;
1987
await expect(lastForkHandler!(sessionResource, request, CancellationToken.None)).rejects.toThrow(/session not found/i);
1988
});
1989
1990
it('throws when request message is not found in session', async () => {
1991
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
1992
lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });
1993
1994
const session = makeSession('sess-1', [{ uuid: 'msg-1', type: 'user' }]);
1995
vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);
1996
1997
const request = { id: 'nonexistent', prompt: 'test' } as vscode.ChatRequestTurn2;
1998
await expect(lastForkHandler!(sessionResource, request, CancellationToken.None)).rejects.toThrow(/could not be found/i);
1999
});
2000
2001
it('throws when trying to fork at the first message', async () => {
2002
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
2003
lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });
2004
2005
const session = makeSession('sess-1', [{ uuid: 'msg-1', type: 'user' }]);
2006
vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any);
2007
2008
const request = { id: 'msg-1', prompt: 'test' } as vscode.ChatRequestTurn2;
2009
await expect(lastForkHandler!(sessionResource, request, CancellationToken.None)).rejects.toThrow(/first message/i);
2010
});
2011
2012
it('adds the forked item to the controller items', async () => {
2013
const sessionResource = ClaudeSessionUri.forSessionId('sess-1');
2014
lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original' });
2015
2016
await lastForkHandler!(sessionResource, undefined, CancellationToken.None);
2017
2018
const forkedItem = getItem('forked-session-id');
2019
expect(forkedItem).toBeDefined();
2020
expect(forkedItem!.iconPath).toBeDefined();
2021
expect(forkedItem!.timing).toBeDefined();
2022
});
2023
});
2024
2025
// #endregion
2026
2027
// #region Session metadata enrichment
2028
2029
describe('session metadata enrichment', () => {
2030
it('includes enriched git metadata when repository exists', async () => {
2031
const gitService = new MockGitService();
2032
const repoCtx = buildRepoContext({
2033
rootUri: URI.file('/home/user/my-project'),
2034
headBranchName: 'feature-branch',
2035
upstreamRemote: 'origin',
2036
upstreamBranchName: 'feature-branch',
2037
headIncomingChanges: 2,
2038
headOutgoingChanges: 3,
2039
remoteFetchUrls: ['https://github.com/owner/repo.git'],
2040
changes: {
2041
mergeChanges: [],
2042
indexChanges: [mockChange(), mockChange()],
2043
workingTree: [mockChange()],
2044
untrackedChanges: [],
2045
},
2046
});
2047
vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
2048
controller = createController([URI.file('/project')], gitService);
2049
2050
const diskSession = buildDiskSession('enriched-meta');
2051
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
2052
2053
await controller.updateItemStatus('enriched-meta', ChatSessionStatus.InProgress, 'Prompt');
2054
2055
const item = getItem('enriched-meta');
2056
expect(item!.metadata).toEqual({
2057
workingDirectoryPath: '/home/user/my-project',
2058
repositoryPath: URI.file('/home/user/my-project').fsPath,
2059
branchName: 'feature-branch',
2060
upstreamBranchName: 'origin/feature-branch',
2061
hasGitHubRemote: true,
2062
incomingChanges: 2,
2063
outgoingChanges: 3,
2064
uncommittedChanges: 3,
2065
});
2066
});
2067
2068
it('sets upstreamBranchName to undefined when no upstream remote', async () => {
2069
const gitService = new MockGitService();
2070
const repoCtx = buildRepoContext({
2071
rootUri: URI.file('/home/user/my-project'),
2072
headBranchName: 'local-only',
2073
upstreamRemote: undefined,
2074
upstreamBranchName: undefined,
2075
});
2076
vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
2077
controller = createController([URI.file('/project')], gitService);
2078
2079
const diskSession = buildDiskSession('no-upstream');
2080
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
2081
2082
await controller.updateItemStatus('no-upstream', ChatSessionStatus.InProgress, 'Prompt');
2083
2084
const item = getItem('no-upstream');
2085
expect(item!.metadata).toMatchObject({
2086
branchName: 'local-only',
2087
upstreamBranchName: undefined,
2088
});
2089
});
2090
2091
it('sums uncommittedChanges from all change categories', async () => {
2092
const gitService = new MockGitService();
2093
const repoCtx = buildRepoContext({
2094
rootUri: URI.file('/home/user/my-project'),
2095
changes: {
2096
mergeChanges: [mockChange(), mockChange()],
2097
indexChanges: [mockChange(), mockChange(), mockChange()],
2098
workingTree: [mockChange()],
2099
untrackedChanges: [mockChange(), mockChange(), mockChange(), mockChange()],
2100
},
2101
});
2102
vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
2103
controller = createController([URI.file('/project')], gitService);
2104
2105
const diskSession = buildDiskSession('many-changes');
2106
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
2107
2108
await controller.updateItemStatus('many-changes', ChatSessionStatus.InProgress, 'Prompt');
2109
2110
const item = getItem('many-changes');
2111
expect(item!.metadata).toMatchObject({ uncommittedChanges: 10 });
2112
});
2113
2114
it('sets uncommittedChanges to 0 when changes is undefined', async () => {
2115
const gitService = new MockGitService();
2116
const repoCtx = buildRepoContext({
2117
rootUri: URI.file('/home/user/my-project'),
2118
changes: undefined,
2119
});
2120
vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
2121
controller = createController([URI.file('/project')], gitService);
2122
2123
const diskSession = buildDiskSession('no-changes');
2124
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
2125
2126
await controller.updateItemStatus('no-changes', ChatSessionStatus.InProgress, 'Prompt');
2127
2128
const item = getItem('no-changes');
2129
expect(item!.metadata).toMatchObject({ uncommittedChanges: 0 });
2130
});
2131
2132
it('sets hasGitHubRemote to false when no GitHub remote', async () => {
2133
const gitService = new MockGitService();
2134
const repoCtx = buildRepoContext({
2135
rootUri: URI.file('/home/user/my-project'),
2136
remoteFetchUrls: ['https://gitlab.com/owner/repo.git'],
2137
});
2138
vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
2139
controller = createController([URI.file('/project')], gitService);
2140
2141
const diskSession = buildDiskSession('no-github');
2142
vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
2143
2144
await controller.updateItemStatus('no-github', ChatSessionStatus.InProgress, 'Prompt');
2145
2146
const item = getItem('no-github');
2147
expect(item!.metadata).toMatchObject({ hasGitHubRemote: false });
2148
});
2149
});
2150
2151
// #endregion
2152
2153
// #region Command handlers
2154
2155
describe('command handlers', () => {
2156
it('commit command sends /commit prompt to the session', async () => {
2157
createController([URI.file('/project')]);
2158
const resource = ClaudeSessionUri.forSessionId('test-session');
2159
2160
await findCommandHandler('github.copilot.claude.sessions.commit')(resource);
2161
2162
expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
2163
'workbench.action.chat.openSessionWithPrompt.claude-code',
2164
{ resource, prompt: builtinSlashCommands.commit },
2165
);
2166
});
2167
2168
it('commitAndSync command sends combined /commit and /sync prompt', async () => {
2169
createController([URI.file('/project')]);
2170
const resource = ClaudeSessionUri.forSessionId('test-session');
2171
2172
await findCommandHandler('github.copilot.claude.sessions.commitAndSync')(resource);
2173
2174
expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
2175
'workbench.action.chat.openSessionWithPrompt.claude-code',
2176
{ resource, prompt: `${builtinSlashCommands.commit} and ${builtinSlashCommands.sync}` },
2177
);
2178
});
2179
2180
it('sync command sends /sync prompt to the session', async () => {
2181
createController([URI.file('/project')]);
2182
const resource = ClaudeSessionUri.forSessionId('test-session');
2183
2184
await findCommandHandler('github.copilot.claude.sessions.sync')(resource);
2185
2186
expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
2187
'workbench.action.chat.openSessionWithPrompt.claude-code',
2188
{ resource, prompt: builtinSlashCommands.sync },
2189
);
2190
});
2191
2192
it('commit command extracts resource from ChatSessionItem', async () => {
2193
createController([URI.file('/project')]);
2194
const resource = ClaudeSessionUri.forSessionId('test-session');
2195
const sessionItem = { resource, label: 'Test' };
2196
2197
await findCommandHandler('github.copilot.claude.sessions.commit')(sessionItem);
2198
2199
expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
2200
'workbench.action.chat.openSessionWithPrompt.claude-code',
2201
{ resource, prompt: builtinSlashCommands.commit },
2202
);
2203
});
2204
2205
it('commands do not execute when resource is undefined', async () => {
2206
createController([URI.file('/project')]);
2207
2208
await findCommandHandler('github.copilot.claude.sessions.commit')(undefined);
2209
await findCommandHandler('github.copilot.claude.sessions.commitAndSync')(undefined);
2210
await findCommandHandler('github.copilot.claude.sessions.sync')(undefined);
2211
2212
expect(vscodeShim.commands.executeCommand).not.toHaveBeenCalled();
2213
});
2214
2215
it('initializeRepository calls gitService.initRepository with workspace folder', async () => {
2216
const gitService = new MockGitService();
2217
const initSpy = vi.spyOn(gitService, 'initRepository').mockResolvedValue({} as Repository);
2218
controller = createController([], gitService);
2219
2220
const sessionId = 'init-repo-session';
2221
const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);
2222
sessionStateService.setFolderInfoForSession(sessionId, {
2223
cwd: '/home/user/my-project',
2224
additionalDirectories: [],
2225
});
2226
2227
const resource = ClaudeSessionUri.forSessionId(sessionId);
2228
await findCommandHandler('github.copilot.claude.sessions.initializeRepository')(resource);
2229
2230
expect(initSpy).toHaveBeenCalledWith(URI.file('/home/user/my-project'));
2231
});
2232
2233
it('initializeRepository does not throw when init returns undefined', async () => {
2234
const gitService = new MockGitService();
2235
vi.spyOn(gitService, 'initRepository').mockResolvedValue(undefined);
2236
controller = createController([], gitService);
2237
2238
const sessionId = 'init-fail-session';
2239
const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);
2240
sessionStateService.setFolderInfoForSession(sessionId, {
2241
cwd: '/home/user/my-project',
2242
additionalDirectories: [],
2243
});
2244
2245
const resource = ClaudeSessionUri.forSessionId(sessionId);
2246
await findCommandHandler('github.copilot.claude.sessions.initializeRepository')(resource);
2247
});
2248
});
2249
2250
// #endregion
2251
});
2252
2253
function createClaudeSessionUri(id: string): URI {
2254
return URI.parse(`claude-code:/${id}`);
2255
}
2256
2257