Skip to content

Commit f3e4709

Browse files
authored
Merge branch 'main' into justin/carbink
2 parents 1013818 + 1d8e3cb commit f3e4709

8 files changed

Lines changed: 169 additions & 9 deletions

File tree

extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
8989

9090
// #region Chat Participant Handler
9191

92+
provideHandleOptionsChange(resource: vscode.Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, _token: vscode.CancellationToken): void {
93+
const sessionId = ClaudeSessionUri.getSessionId(resource);
94+
for (const update of updates) {
95+
const value = update.value;
96+
if (update.optionId === PERMISSION_MODE_OPTION_ID && value && isPermissionMode(value)) {
97+
this.sessionStateService.setPermissionModeForSession(sessionId, value);
98+
}
99+
}
100+
}
101+
92102
createHandler(): ChatExtendedRequestHandler {
93103
return async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> => {
94104
const { chatSessionContext } = context;

extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLI
5858
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
5959

6060
const REPOSITORY_OPTION_ID = 'repository';
61+
const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
6162

6263
const _sessionWorktreeIsolationCache = new Map<string, boolean>();
6364
const BRANCH_OPTION_ID = 'branch';
@@ -578,6 +579,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
578579
private _currentSessionId: string | undefined;
579580
private _selectedRepoForBranches: { repoUri: URI; headBranchName: string | undefined } | undefined;
580581
private _displayedOptionIds = new Set<string>();
582+
private readonly _activeSessionsById = new Map<string, ICopilotCLISession>();
581583
/**
582584
* ID of the last used folder in an untitled workspace (for defaulting selection).
583585
*/
@@ -1076,7 +1078,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
10761078
const wasBranchOptionShow = !!this._selectedRepoForBranches;
10771079
let triggerProviderOptionsChange = false;
10781080
for (const update of updates) {
1079-
if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && this.sessionItemProvider.isNewSession(sessionId)) {
1081+
if (update.optionId === PERMISSION_LEVEL_OPTION_ID) {
1082+
const level = typeof update.value === 'string' ? update.value : undefined;
1083+
this._getActiveSessionForResourceId(sessionId)?.setPermissionLevel(level);
1084+
} else if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && this.sessionItemProvider.isNewSession(sessionId)) {
10801085
const folder = vscode.Uri.file(update.value);
10811086
if (isEqual(folder, this._selectedRepoForBranches?.repoUri)) {
10821087
continue;
@@ -1184,6 +1189,29 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
11841189
}
11851190
}
11861191

1192+
private _getActiveSessionForResourceId(sessionId: string): ICopilotCLISession | undefined {
1193+
return this._activeSessionsById.get(this.sessionItemProvider.untitledSessionIdMapping.get(sessionId) ?? sessionId)
1194+
?? this._activeSessionsById.get(sessionId);
1195+
}
1196+
1197+
trackActiveSession(resourceSessionId: string, session: ICopilotCLISession): void {
1198+
this._activeSessionsById.set(resourceSessionId, session);
1199+
this._activeSessionsById.set(session.sessionId, session);
1200+
}
1201+
1202+
untrackActiveSession(resourceSessionId: string | undefined, session: ICopilotCLISession | undefined, hasPendingRequests: boolean): void {
1203+
if (!session || hasPendingRequests) {
1204+
return;
1205+
}
1206+
1207+
if (resourceSessionId && this._activeSessionsById.get(resourceSessionId) === session) {
1208+
this._activeSessionsById.delete(resourceSessionId);
1209+
}
1210+
if (this._activeSessionsById.get(session.sessionId) === session) {
1211+
this._activeSessionsById.delete(session.sessionId);
1212+
}
1213+
}
1214+
11871215
}
11881216

11891217
function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {
@@ -1348,7 +1376,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
13481376
const disposables = new DisposableStore();
13491377
let sessionId: string | undefined = undefined;
13501378
let sessionParentId: string | undefined = undefined;
1379+
let sessionPermissionLevel: string | undefined = undefined;
13511380
let sdkSessionId: string | undefined = undefined;
1381+
let activeSession: ICopilotCLISession | undefined;
13521382
try {
13531383

13541384
const initialOptions = chatSessionContext?.initialSessionOptions;
@@ -1365,6 +1395,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
13651395
_sessionBranch.set(sessionId, value);
13661396
} else if (opt.optionId === ISOLATION_OPTION_ID && value) {
13671397
_sessionIsolation.set(sessionId, value as IsolationMode);
1398+
} else if (opt.optionId === PERMISSION_LEVEL_OPTION_ID && value) {
1399+
sessionPermissionLevel = value;
13681400
} else if (opt.optionId === PARENT_SESSION_OPTION_ID && value) {
13691401
sessionParentId = value;
13701402
}
@@ -1453,7 +1485,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
14531485
};
14541486
const newBranch = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : undefined;
14551487

1456-
const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId }, disposables, token);
1488+
const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId, permissionLevel: sessionPermissionLevel }, disposables, token);
14571489
const session = sessionResult.session;
14581490
if (session) {
14591491
disposables.add(session);
@@ -1472,6 +1504,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
14721504
}
14731505

14741506
sdkSessionId = session.object.sessionId;
1507+
activeSession = session.object;
1508+
this.contentProvider.trackActiveSession(sessionId, activeSession);
14751509
const modeInstructions = this.createModeInstructions(request);
14761510
this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
14771511

@@ -1565,6 +1599,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
15651599
}
15661600
}
15671601
}
1602+
this.contentProvider.untrackActiveSession(sessionId, activeSession, sdkSessionId ? this.pendingRequestBySession.has(sdkSessionId) : false);
15681603
if (chatSessionContext?.chatSessionItem.resource) {
15691604
this.sessionItemProvider.notifySessionsChange();
15701605
}
@@ -1831,7 +1866,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
18311866
}
18321867
}
18331868

1834-
private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise<string | undefined>; sessionParentId?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; trusted: boolean }> {
1869+
private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise<string | undefined>; sessionParentId?: string; permissionLevel?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; trusted: boolean }> {
18351870
const { resource } = chatSessionContext.chatSessionItem;
18361871
const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource));
18371872
const id = existingSessionId ?? SessionIdForCLI.parse(resource);
@@ -1872,7 +1907,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
18721907
void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, sessionWorkingDirectory.fsPath, session.object.workspace.repositoryProperties);
18731908
}
18741909
disposables.add(session.object.attachStream(stream));
1875-
const permissionLevel = request.permissionLevel;
1910+
const permissionLevel = request.permissionLevel ?? options.permissionLevel;
18761911
session.object.setPermissionLevel(permissionLevel);
18771912

18781913
return { session, trusted };

extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,20 @@ describe('ChatSessionContentProvider', () => {
11041104
expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default');
11051105
});
11061106

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+
11071121
it('external permission change syncs into a previousInputState-restored pipeline', async () => {
11081122
const mocks = createDefaultMocks();
11091123
const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);

extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo'
4747
import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService';
4848
import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli';
4949
import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver';
50-
import { CopilotCLISession, CopilotCLISessionInput } from '../../copilotcli/node/copilotcliSession';
50+
import { CopilotCLISession, CopilotCLISessionInput, ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';
5151
import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
5252
import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler';
5353
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers';
@@ -250,6 +250,7 @@ function createChatContext(sessionId: string, isUntitled: boolean, ...requests:
250250

251251
class TestCopilotCLISession extends CopilotCLISession {
252252
public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; model: { model: string; reasoningEffort?: string } | undefined; authInfo: NonNullable<SessionOptions['authInfo']>; token: vscode.CancellationToken }> = [];
253+
public permissionLevel: string | undefined;
253254
public static nextHandleRequestResult: Promise<void> | undefined;
254255
public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput) => Promise<void>) | undefined;
255256
public static statusOverride?: vscode.ChatSessionStatus;
@@ -263,6 +264,10 @@ class TestCopilotCLISession extends CopilotCLISession {
263264
}
264265
return TestCopilotCLISession.nextHandleRequestResult ?? Promise.resolve();
265266
}
267+
override setPermissionLevel(level: string | undefined): void {
268+
this.permissionLevel = level;
269+
super.setPermissionLevel(level);
270+
}
266271
}
267272

268273

@@ -405,6 +410,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
405410
override notifySessionOptionsChange = vi.fn((_resource: vscode.Uri, _updates: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }>): void => {
406411
// tracked by vi.fn
407412
});
413+
override trackActiveSession = vi.fn();
414+
override untrackActiveSession = vi.fn();
408415
}();
409416
folderRepositoryManager = new CopilotCLIFolderRepositoryManager(
410417
worktree,
@@ -468,6 +475,71 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
468475
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], model: { model: 'base' }, authInfo, token });
469476
});
470477

478+
it('uses permissionLevel from initial session options', async () => {
479+
const request = new TestChatRequest('Say hi');
480+
const context = createChatContext('temp-new', true, request);
481+
(context.chatSessionContext as { initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }> }).initialSessionOptions = [{ optionId: 'permissionLevel', value: 'autopilot' }];
482+
const stream = new MockChatResponseStream();
483+
const token = disposables.add(new CancellationTokenSource()).token;
484+
485+
await participant.createHandler()(request, context, stream, token);
486+
487+
expect(cliSessions.length).toBe(1);
488+
expect(cliSessions[0].permissionLevel).toBe('autopilot');
489+
});
490+
491+
it('applies live permissionLevel option changes to an active session', async () => {
492+
const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
493+
(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
494+
(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();
495+
const activeSession = {
496+
sessionId: 'sdk-session',
497+
setPermissionLevel: vi.fn(),
498+
} as unknown as ICopilotCLISession;
499+
itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);
500+
provider.trackActiveSession('untitled-session', activeSession);
501+
502+
await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [
503+
{ optionId: 'permissionLevel', value: 'autopilot' }
504+
], disposables.add(new CancellationTokenSource()).token);
505+
506+
expect(activeSession.setPermissionLevel).toHaveBeenCalledWith('autopilot');
507+
});
508+
509+
it('scopes live permissionLevel changes to the targeted session', async () => {
510+
const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
511+
(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
512+
(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();
513+
const sessionA = { sessionId: 'sdk-a', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
514+
const sessionB = { sessionId: 'sdk-b', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
515+
itemProvider.untitledSessionIdMapping.set('resource-a', sessionA.sessionId);
516+
itemProvider.untitledSessionIdMapping.set('resource-b', sessionB.sessionId);
517+
provider.trackActiveSession('resource-a', sessionA);
518+
provider.trackActiveSession('resource-b', sessionB);
519+
520+
await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/resource-b'), [
521+
{ optionId: 'permissionLevel', value: 'autopilot' }
522+
], disposables.add(new CancellationTokenSource()).token);
523+
524+
expect(sessionB.setPermissionLevel).toHaveBeenCalledWith('autopilot');
525+
expect(sessionA.setPermissionLevel).not.toHaveBeenCalled();
526+
});
527+
528+
it('clears permissionLevel on an active session when option value is undefined', async () => {
529+
const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
530+
(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
531+
(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();
532+
const activeSession = { sessionId: 'sdk-session', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
533+
itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);
534+
provider.trackActiveSession('untitled-session', activeSession);
535+
536+
await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [
537+
{ optionId: 'permissionLevel', value: undefined }
538+
], disposables.add(new CancellationTokenSource()).token);
539+
540+
expect(activeSession.setPermissionLevel).toHaveBeenCalledWith(undefined);
541+
});
542+
471543
it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => {
472544
const worktreeProperties = {
473545
autoCommit: true,

src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOp
1515
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
1616
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
1717
import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js';
18+
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
1819

1920
const PERMISSION_MODE_OPTION_ID = 'permissionMode';
2021

@@ -56,6 +57,7 @@ export class ClaudePermissionModePicker extends Disposable {
5657
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
5758
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
5859
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
60+
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
5961
) {
6062
super();
6163
}
@@ -140,7 +142,16 @@ export class ClaudePermissionModePicker extends Disposable {
140142
}
141143
const provider = this.sessionsProvidersService.getProvider(session.providerId);
142144
if (provider instanceof CopilotChatSessionsProvider) {
143-
provider.getSession(session.sessionId)?.setOption?.(PERMISSION_MODE_OPTION_ID, { id: mode.id, name: mode.label });
145+
const chatSession = provider.getSession(session.sessionId);
146+
if (!chatSession) {
147+
return;
148+
}
149+
const option = { id: mode.id, name: mode.label };
150+
if (chatSession.setOption) {
151+
chatSession.setOption(PERMISSION_MODE_OPTION_ID, option);
152+
} else {
153+
this.chatSessionsService.setSessionOption(chatSession.resource, PERMISSION_MODE_OPTION_ID, option);
154+
}
144155
}
145156
}
146157

src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js';
2323
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
2424
import { URI } from '../../../../base/common/uri.js';
2525
import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';
26+
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
27+
28+
const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
2629

2730
/**
2831
* Strategy for the per-provider parts of {@link PermissionPicker}: how to read
@@ -348,6 +351,7 @@ export class CopilotPermissionPickerDelegate extends Disposable implements IPerm
348351
constructor(
349352
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
350353
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
354+
@IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,
351355
) {
352356
super();
353357
}
@@ -359,7 +363,16 @@ export class CopilotPermissionPickerDelegate extends Disposable implements IPerm
359363
}
360364
const provider = this._sessionsProvidersService.getProvider(session.providerId);
361365
if (provider instanceof CopilotChatSessionsProvider) {
362-
provider.getSession(session.sessionId)?.setPermissionLevel(level);
366+
const chatSession = provider.getSession(session.sessionId);
367+
if (!chatSession) {
368+
return;
369+
}
370+
if (chatSession.setOption) {
371+
chatSession.setPermissionLevel(level);
372+
chatSession.setOption(PERMISSION_LEVEL_OPTION_ID, level);
373+
} else {
374+
this._chatSessionsService.setSessionOption(chatSession.resource, PERMISSION_LEVEL_OPTION_ID, level);
375+
}
363376
}
364377
}
365378
}

0 commit comments

Comments
 (0)