diff --git a/build/.moduleignore b/build/.moduleignore
index f83624f893c37..d199042ac937d 100644
--- a/build/.moduleignore
+++ b/build/.moduleignore
@@ -159,6 +159,14 @@ vsda/**
@vscode/policy-watcher/index.d.ts
!@vscode/policy-watcher/build/Release/vscode-policy-watcher.node
+@vscode/macos-keychain/build/**
+@vscode/macos-keychain/src/**
+@vscode/macos-keychain/test/**
+@vscode/macos-keychain/binding.gyp
+@vscode/macos-keychain/README.md
+@vscode/macos-keychain/index.d.ts
+!@vscode/macos-keychain/build/Release/keychainNative.node
+
@vscode/windows-ca-certs/**/*
!@vscode/windows-ca-certs/package.json
!@vscode/windows-ca-certs/**/*.node
diff --git a/build/azure-pipelines/darwin/app-entitlements.plist b/build/azure-pipelines/darwin/app-entitlements.plist
index 4073eafcf560d..db62570f361b9 100644
--- a/build/azure-pipelines/darwin/app-entitlements.plist
+++ b/build/azure-pipelines/darwin/app-entitlements.plist
@@ -10,5 +10,9 @@
com.apple.security.automation.apple-events
+ keychain-access-groups
+
+ $(TeamIdentifierPrefix)com.microsoft.vscode.shared-secrets
+
diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml
index a90e25aeabe36..f5972f6224d86 100644
--- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml
+++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml
@@ -332,6 +332,16 @@ steps:
BUILD_SOURCESDIRECTORY: $(Build.SourcesDirectory)
displayName: ✍️ Codesign & Notarize
+ # Re-sign the app without the provisioning profile for tests.
+ # This strips the keychain-access-groups entitlement which requires a
+ # provisioning profile and is not needed for running tests. The codesign
+ # step reads from the archives packaged above which have the full entitlements.
+ - script: |
+ set -e
+ export CODESIGN_IDENTITY=$(security find-identity -v -p codesigning $(agent.tempdirectory)/buildagent.keychain | grep -oEi "([0-9A-F]{40})" | head -n 1)
+ DEBUG=electron-osx-sign* node build/darwin/sign.ts --skip-provisioning-profile $(agent.builddirectory)
+ displayName: Set Hardened Entitlements (for tests)
+
- ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}:
- template: product-build-darwin-test.yml@self
parameters:
diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts
index 26e22aee08c88..fc53479855cac 100644
--- a/build/darwin/sign.ts
+++ b/build/darwin/sign.ts
@@ -18,7 +18,14 @@ function getElectronVersion(): string {
return target;
}
-function getEntitlementsForFile(filePath: string): string {
+const mainProvisioningProfilePath = path.join(baseDir, 'darwin', 'main.provisionprofile');
+const agentsProvisioningProfilePath = path.join(baseDir, 'darwin', 'agents.provisionprofile');
+
+function hasProvisioningProfile(): boolean {
+ return fs.existsSync(mainProvisioningProfilePath);
+}
+
+function getEntitlementsForFile(filePath: string, tempDir: string, useProvisioningProfile: boolean, teamId?: string): string {
if (filePath.includes(' Helper (GPU).app')) {
return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-gpu-entitlements.plist');
} else if (filePath.includes(' Helper (Renderer).app')) {
@@ -26,7 +33,51 @@ function getEntitlementsForFile(filePath: string): string {
} else if (filePath.includes(' Helper (Plugin).app')) {
return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-plugin-entitlements.plist');
}
- return path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist');
+ const entitlementsPath = path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist');
+ if (!useProvisioningProfile) {
+ // Without a provisioning profile, keychain-access-groups entitlement
+ // will cause signing failures. Strip it from the entitlements plist.
+ return getStrippedEntitlements(entitlementsPath, tempDir);
+ }
+ if (teamId) {
+ return getExpandedEntitlements(entitlementsPath, tempDir, teamId);
+ }
+ return entitlementsPath;
+}
+
+let _strippedEntitlementsPath: string | undefined;
+
+/**
+ * Returns a path to a copy of the entitlements plist with the
+ * keychain-access-groups key removed.
+ */
+function getStrippedEntitlements(entitlementsPath: string, tempDir: string): string {
+ if (!_strippedEntitlementsPath) {
+ const content = fs.readFileSync(entitlementsPath, 'utf8');
+ const stripped = content.replace(
+ /\s*keychain-access-groups<\/key>\s*[\s\S]*?<\/array>/,
+ ''
+ );
+ _strippedEntitlementsPath = path.join(tempDir, 'app-entitlements-stripped.plist');
+ fs.writeFileSync(_strippedEntitlementsPath, stripped);
+ }
+ return _strippedEntitlementsPath;
+}
+
+let expandedEntitlementsPath: string | undefined;
+
+/**
+ * Returns a path to a copy of the entitlements plist with
+ * $(TeamIdentifierPrefix) expanded to the actual team identifier.
+ */
+function getExpandedEntitlements(entitlementsPath: string, tempDir: string, teamId: string): string {
+ if (!expandedEntitlementsPath) {
+ const content = fs.readFileSync(entitlementsPath, 'utf8');
+ const expanded = content.replace(/\$\(TeamIdentifierPrefix\)/g, teamId + '.');
+ expandedEntitlementsPath = path.join(tempDir, 'app-entitlements.plist');
+ fs.writeFileSync(expandedEntitlementsPath, expanded);
+ }
+ return expandedEntitlementsPath;
}
async function retrySignOnKeychainError(fn: () => Promise, maxRetries: number = 3): Promise {
@@ -58,7 +109,7 @@ async function retrySignOnKeychainError(fn: () => Promise, maxRetries: num
throw lastError;
}
-async function main(buildDir?: string): Promise {
+async function main(buildDir?: string, skipProvisioningProfile?: boolean): Promise {
const tempDir = process.env['AGENT_TEMPDIRECTORY'];
const arch = process.env['VSCODE_ARCH'];
const identity = process.env['CODESIGN_IDENTITY'];
@@ -78,15 +129,42 @@ async function main(buildDir?: string): Promise {
? path.resolve(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameLong}.app`, 'Contents', 'Info.plist')
: undefined;
+ const useProvisioningProfile = !skipProvisioningProfile && hasProvisioningProfile();
+ const resolvedProvisioningProfile = useProvisioningProfile ? mainProvisioningProfilePath : undefined;
+
+ let teamId: string | undefined;
+ if (resolvedProvisioningProfile) {
+ const profilePlist = await spawn('security', ['cms', '-D', '-i', resolvedProvisioningProfile]);
+ const teamIdMatch = /TeamIdentifier<\/key>\s*\s*(.*?)<\/string>/s.exec(profilePlist);
+ if (teamIdMatch) {
+ teamId = teamIdMatch[1];
+ console.log(`Extracted TeamIdentifier from provisioning profile: ${teamId}`);
+ } else {
+ console.warn('Could not extract TeamIdentifier from provisioning profile; $(TeamIdentifierPrefix) will not be expanded');
+ }
+ }
+
+ // Embed the agents provisioning profile into the embedded app bundle
+ // before signing, since @electron/osx-sign only supports one top-level profile.
+ if (useProvisioningProfile && product.embedded && fs.existsSync(agentsProvisioningProfilePath)) {
+ const embeddedAppPath = path.join(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameLong}.app`);
+ if (fs.existsSync(embeddedAppPath)) {
+ const embeddedProfileDest = path.join(embeddedAppPath, 'Contents', 'embedded.provisionprofile');
+ fs.copyFileSync(agentsProvisioningProfilePath, embeddedProfileDest);
+ console.log(`Embedded agents provisioning profile into ${embeddedProfileDest}`);
+ }
+ }
+
const appOpts: SignOptions = {
app: path.join(appRoot, appName),
platform: 'darwin',
optionsForFile: (filePath) => ({
- entitlements: getEntitlementsForFile(filePath),
+ entitlements: getEntitlementsForFile(filePath, tempDir, useProvisioningProfile, teamId),
hardenedRuntime: true,
}),
preAutoEntitlements: false,
- preEmbedProvisioningProfile: false,
+ preEmbedProvisioningProfile: !!resolvedProvisioningProfile,
+ provisioningProfile: resolvedProvisioningProfile,
keychain: path.join(tempDir, 'buildagent.keychain'),
version: getElectronVersion(),
identity,
@@ -94,7 +172,8 @@ async function main(buildDir?: string): Promise {
// Only overwrite plist entries for x64 and arm64 builds,
// universal will get its copy from the x64 build.
- if (arch !== 'universal') {
+ // Skip when re-signing (skipProvisioningProfile) since entries already exist.
+ if (arch !== 'universal' && !skipProvisioningProfile) {
await spawn('plutil', [
'-insert',
'NSAppleEventsUsageDescription',
@@ -171,10 +250,19 @@ async function main(buildDir?: string): Promise {
}
await retrySignOnKeychainError(() => sign(appOpts));
+
+ // Dump entitlements from the signed binary for diagnostic purposes
+ const mainBinary = path.join(appRoot, appName, 'Contents', 'MacOS', product.nameShort);
+ console.log(`Dumping entitlements from signed binary: ${mainBinary}`);
+ const entitlementsDump = await spawn('codesign', ['--display', '--entitlements', '-', '--xml', mainBinary]);
+ console.log(`Signed entitlements:\n${entitlementsDump}`);
}
if (import.meta.main) {
- main(process.argv[2]).catch(async err => {
+ const args = process.argv.slice(2);
+ const skipProvisioningProfile = args.includes('--skip-provisioning-profile');
+ const buildDir = args.filter(a => !a.startsWith('--'))[0];
+ main(buildDir, skipProvisioningProfile).catch(async err => {
console.error(err);
const tempDir = process.env['AGENT_TEMPDIRECTORY'];
if (tempDir) {
diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json
index 79dd3b42ff176..f7a77f670bb02 100644
--- a/extensions/copilot/package.json
+++ b/extensions/copilot/package.json
@@ -3815,6 +3815,15 @@
"clear-both"
]
},
+ "github.copilot.chat.anthropic.cacheBreakpoints.lastTwoMessages": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "%github.copilot.config.anthropic.cacheBreakpoints.lastTwoMessages%",
+ "tags": [
+ "experimental",
+ "onExp"
+ ]
+ },
"github.copilot.chat.responsesApiReasoningSummary": {
"type": "string",
"default": "detailed",
diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json
index 5eb1a8178da2d..1c64b31759544 100644
--- a/extensions/copilot/package.nls.json
+++ b/extensions/copilot/package.nls.json
@@ -342,6 +342,7 @@
"copilot.toolSet.web.description": "Fetch information from the web",
"github.copilot.config.useMessagesApi": "Use the Messages API instead of the Chat Completions API when supported.",
"github.copilot.config.anthropic.contextEditing.mode": "Select the context editing mode for Anthropic models. Automatically manages conversation context as it grows, helping optimize costs and stay within context window limits.\n\n- `off`: Context editing is disabled.\n- `clear-thinking`: Clears thinking blocks while preserving tool uses.\n- `clear-tooluse`: Clears tool uses while preserving thinking blocks.\n- `clear-both`: Clears both thinking blocks and tool uses.\n\n**Note**: This is an experimental feature. Context editing may cause additional cache rewrites. Enable with caution.",
+ "github.copilot.config.anthropic.cacheBreakpoints.lastTwoMessages": "Use the 'last two messages' cache breakpoint strategy instead of heuristic-based placement for Anthropic Messages API.",
"github.copilot.config.useResponsesApi": "Use the Responses API instead of the Chat Completions API when supported. Enables reasoning and reasoning summaries.\n\n**Note**: This is an experimental feature that is not yet activated for all users.\n\n**Important**: URL API path resolution for custom OpenAI-compatible and Azure models is independent of this setting and fully determined by `url` property of `#github.copilot.chat.customOAIModels#` or `#github.copilot.chat.azureModels#` respectively.",
"github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.",
"github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.",
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
index 4cd6a9c44e2f8..15e13c50c8e92 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
@@ -89,6 +89,16 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
// #region Chat Participant Handler
+ provideHandleOptionsChange(resource: vscode.Uri, updates: ReadonlyArray, _token: vscode.CancellationToken): void {
+ const sessionId = ClaudeSessionUri.getSessionId(resource);
+ for (const update of updates) {
+ const value = update.value;
+ if (update.optionId === PERMISSION_MODE_OPTION_ID && value && isPermissionMode(value)) {
+ this.sessionStateService.setPermissionModeForSession(sessionId, value);
+ }
+ }
+ }
+
createHandler(): ChatExtendedRequestHandler {
return async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise => {
const { chatSessionContext } = context;
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
index 4c7d156c7e8b9..e650294489cb8 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
@@ -58,6 +58,7 @@ import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLI
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
const REPOSITORY_OPTION_ID = 'repository';
+const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
const _sessionWorktreeIsolationCache = new Map();
const BRANCH_OPTION_ID = 'branch';
@@ -578,6 +579,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
private _currentSessionId: string | undefined;
private _selectedRepoForBranches: { repoUri: URI; headBranchName: string | undefined } | undefined;
private _displayedOptionIds = new Set();
+ private readonly _activeSessionsById = new Map();
/**
* ID of the last used folder in an untitled workspace (for defaulting selection).
*/
@@ -1076,7 +1078,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
const wasBranchOptionShow = !!this._selectedRepoForBranches;
let triggerProviderOptionsChange = false;
for (const update of updates) {
- if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && this.sessionItemProvider.isNewSession(sessionId)) {
+ if (update.optionId === PERMISSION_LEVEL_OPTION_ID) {
+ const level = typeof update.value === 'string' ? update.value : undefined;
+ this._getActiveSessionForResourceId(sessionId)?.setPermissionLevel(level);
+ } else if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && this.sessionItemProvider.isNewSession(sessionId)) {
const folder = vscode.Uri.file(update.value);
if (isEqual(folder, this._selectedRepoForBranches?.repoUri)) {
continue;
@@ -1184,6 +1189,29 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
}
+ private _getActiveSessionForResourceId(sessionId: string): ICopilotCLISession | undefined {
+ return this._activeSessionsById.get(this.sessionItemProvider.untitledSessionIdMapping.get(sessionId) ?? sessionId)
+ ?? this._activeSessionsById.get(sessionId);
+ }
+
+ trackActiveSession(resourceSessionId: string, session: ICopilotCLISession): void {
+ this._activeSessionsById.set(resourceSessionId, session);
+ this._activeSessionsById.set(session.sessionId, session);
+ }
+
+ untrackActiveSession(resourceSessionId: string | undefined, session: ICopilotCLISession | undefined, hasPendingRequests: boolean): void {
+ if (!session || hasPendingRequests) {
+ return;
+ }
+
+ if (resourceSessionId && this._activeSessionsById.get(resourceSessionId) === session) {
+ this._activeSessionsById.delete(resourceSessionId);
+ }
+ if (this._activeSessionsById.get(session.sessionId) === session) {
+ this._activeSessionsById.delete(session.sessionId);
+ }
+ }
+
}
function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {
@@ -1348,7 +1376,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
const disposables = new DisposableStore();
let sessionId: string | undefined = undefined;
let sessionParentId: string | undefined = undefined;
+ let sessionPermissionLevel: string | undefined = undefined;
let sdkSessionId: string | undefined = undefined;
+ let activeSession: ICopilotCLISession | undefined;
try {
const initialOptions = chatSessionContext?.initialSessionOptions;
@@ -1365,6 +1395,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
_sessionBranch.set(sessionId, value);
} else if (opt.optionId === ISOLATION_OPTION_ID && value) {
_sessionIsolation.set(sessionId, value as IsolationMode);
+ } else if (opt.optionId === PERMISSION_LEVEL_OPTION_ID && value) {
+ sessionPermissionLevel = value;
} else if (opt.optionId === PARENT_SESSION_OPTION_ID && value) {
sessionParentId = value;
}
@@ -1453,7 +1485,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
};
const newBranch = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : undefined;
- const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId }, disposables, token);
+ const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId, permissionLevel: sessionPermissionLevel }, disposables, token);
const session = sessionResult.session;
if (session) {
disposables.add(session);
@@ -1472,6 +1504,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
sdkSessionId = session.object.sessionId;
+ activeSession = session.object;
+ this.contentProvider.trackActiveSession(sessionId, activeSession);
const modeInstructions = this.createModeInstructions(request);
this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
@@ -1565,6 +1599,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
}
}
+ this.contentProvider.untrackActiveSession(sessionId, activeSession, sdkSessionId ? this.pendingRequestBySession.has(sdkSessionId) : false);
if (chatSessionContext?.chatSessionItem.resource) {
this.sessionItemProvider.notifySessionsChange();
}
@@ -1831,7 +1866,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
}
- private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise; sessionParentId?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> {
+ private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise; sessionParentId?: string; permissionLevel?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> {
const { resource } = chatSessionContext.chatSessionItem;
const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource));
const id = existingSessionId ?? SessionIdForCLI.parse(resource);
@@ -1872,7 +1907,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, sessionWorkingDirectory.fsPath, session.object.workspace.repositoryProperties);
}
disposables.add(session.object.attachStream(stream));
- const permissionLevel = request.permissionLevel;
+ const permissionLevel = request.permissionLevel ?? options.permissionLevel;
session.object.setPermissionLevel(permissionLevel);
return { session, trusted };
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
index 84e648b0121bc..f3afe3ac0d893 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
@@ -1104,6 +1104,20 @@ describe('ChatSessionContentProvider', () => {
expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default');
});
+ it('live permission option changes update session state', async () => {
+ const mocks = createDefaultMocks();
+ const { provider, accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);
+ const sessionStateService = localAccessor.get(IClaudeSessionStateService);
+ const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
+
+ provider.provideHandleOptionsChange(createClaudeSessionUri('live-session'), [
+ { optionId: 'permissionMode', value: 'plan' }
+ ], CancellationToken.None);
+
+ expect(setPermissionSpy).toHaveBeenCalledWith('live-session', 'plan');
+ expect(sessionStateService.getPermissionModeForSession('live-session')).toBe('plan');
+ });
+
it('external permission change syncs into a previousInputState-restored pipeline', async () => {
const mocks = createDefaultMocks();
const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
index 24f16b7055438..5dc299b1ee599 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
@@ -47,7 +47,7 @@ import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo'
import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService';
import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli';
import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver';
-import { CopilotCLISession, CopilotCLISessionInput } from '../../copilotcli/node/copilotcliSession';
+import { CopilotCLISession, CopilotCLISessionInput, ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';
import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler';
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers';
@@ -250,6 +250,7 @@ function createChatContext(sessionId: string, isUntitled: boolean, ...requests:
class TestCopilotCLISession extends CopilotCLISession {
public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; model: { model: string; reasoningEffort?: string } | undefined; authInfo: NonNullable; token: vscode.CancellationToken }> = [];
+ public permissionLevel: string | undefined;
public static nextHandleRequestResult: Promise | undefined;
public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput) => Promise) | undefined;
public static statusOverride?: vscode.ChatSessionStatus;
@@ -263,6 +264,10 @@ class TestCopilotCLISession extends CopilotCLISession {
}
return TestCopilotCLISession.nextHandleRequestResult ?? Promise.resolve();
}
+ override setPermissionLevel(level: string | undefined): void {
+ this.permissionLevel = level;
+ super.setPermissionLevel(level);
+ }
}
@@ -405,6 +410,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
override notifySessionOptionsChange = vi.fn((_resource: vscode.Uri, _updates: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }>): void => {
// tracked by vi.fn
});
+ override trackActiveSession = vi.fn();
+ override untrackActiveSession = vi.fn();
}();
folderRepositoryManager = new CopilotCLIFolderRepositoryManager(
worktree,
@@ -468,6 +475,71 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], model: { model: 'base' }, authInfo, token });
});
+ it('uses permissionLevel from initial session options', async () => {
+ const request = new TestChatRequest('Say hi');
+ const context = createChatContext('temp-new', true, request);
+ (context.chatSessionContext as { initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }> }).initialSessionOptions = [{ optionId: 'permissionLevel', value: 'autopilot' }];
+ const stream = new MockChatResponseStream();
+ const token = disposables.add(new CancellationTokenSource()).token;
+
+ await participant.createHandler()(request, context, stream, token);
+
+ expect(cliSessions.length).toBe(1);
+ expect(cliSessions[0].permissionLevel).toBe('autopilot');
+ });
+
+ it('applies live permissionLevel option changes to an active session', async () => {
+ const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
+ (provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
+ (provider as unknown as { _activeSessionsById: Map })._activeSessionsById = new Map();
+ const activeSession = {
+ sessionId: 'sdk-session',
+ setPermissionLevel: vi.fn(),
+ } as unknown as ICopilotCLISession;
+ itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);
+ provider.trackActiveSession('untitled-session', activeSession);
+
+ await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [
+ { optionId: 'permissionLevel', value: 'autopilot' }
+ ], disposables.add(new CancellationTokenSource()).token);
+
+ expect(activeSession.setPermissionLevel).toHaveBeenCalledWith('autopilot');
+ });
+
+ it('scopes live permissionLevel changes to the targeted session', async () => {
+ const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
+ (provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
+ (provider as unknown as { _activeSessionsById: Map })._activeSessionsById = new Map();
+ const sessionA = { sessionId: 'sdk-a', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
+ const sessionB = { sessionId: 'sdk-b', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
+ itemProvider.untitledSessionIdMapping.set('resource-a', sessionA.sessionId);
+ itemProvider.untitledSessionIdMapping.set('resource-b', sessionB.sessionId);
+ provider.trackActiveSession('resource-a', sessionA);
+ provider.trackActiveSession('resource-b', sessionB);
+
+ await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/resource-b'), [
+ { optionId: 'permissionLevel', value: 'autopilot' }
+ ], disposables.add(new CancellationTokenSource()).token);
+
+ expect(sessionB.setPermissionLevel).toHaveBeenCalledWith('autopilot');
+ expect(sessionA.setPermissionLevel).not.toHaveBeenCalled();
+ });
+
+ it('clears permissionLevel on an active session when option value is undefined', async () => {
+ const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
+ (provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
+ (provider as unknown as { _activeSessionsById: Map })._activeSessionsById = new Map();
+ const activeSession = { sessionId: 'sdk-session', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
+ itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);
+ provider.trackActiveSession('untitled-session', activeSession);
+
+ await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [
+ { optionId: 'permissionLevel', value: undefined }
+ ], disposables.add(new CancellationTokenSource()).token);
+
+ expect(activeSession.setPermissionLevel).toHaveBeenCalledWith(undefined);
+ });
+
it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => {
const worktreeProperties = {
autoCommit: true,
diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts
index 3cb27c243d3c5..274916b0c72e2 100644
--- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts
+++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts
@@ -1115,6 +1115,9 @@ export class TelemetrySender implements IDisposable {
"promptCharCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of characters in the prompt", "isMeasurement": true },
"nDiffsInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of diffs included in the prompt", "isMeasurement": true },
"diffTokensInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of tokens consumed by diffs in the prompt", "isMeasurement": true },
+ "nNeighborSnippetsComputed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of neighbor (similar files) snippets computed before budget filtering", "isMeasurement": true },
+ "nNeighborSnippetsInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of neighbor (similar files) snippets actually included in the prompt", "isMeasurement": true },
+ "neighborSnippetIndicesInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "JSON-encoded array of original input indices (ascending) of neighbor snippets included in the prompt" },
"hadLowLogProbSuggestion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the suggestion had low log probability", "isMeasurement": true },
"nEditsSuggested": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of edits suggested", "isMeasurement": true },
"hasNextEdit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether next edit provider returned an edit (if an edit was previously rejected, this field is false)", "isMeasurement": true },
@@ -1177,6 +1180,7 @@ export class TelemetrySender implements IDisposable {
xtabAggressivenessLevel,
userAggressivenessSetting,
modelConfig,
+ neighborSnippetIndicesInPrompt: telemetry.neighborSnippetIndicesInPrompt,
},
{
requestN,
@@ -1237,6 +1241,8 @@ export class TelemetrySender implements IDisposable {
xtabUserHappinessScore,
nDiffsInPrompt: telemetry.nDiffsInPrompt,
diffTokensInPrompt: telemetry.diffTokensInPrompt,
+ nNeighborSnippetsComputed: telemetry.nNeighborSnippetsComputed,
+ nNeighborSnippetsInPrompt: telemetry.nNeighborSnippetsInPrompt,
}
);
}
diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/similarFilesContext.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/similarFilesContext.ts
index 8591ebd4e59e6..bfd4836049a7b 100644
--- a/extensions/copilot/src/extension/inlineEdits/vscode-node/similarFilesContext.ts
+++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/similarFilesContext.ts
@@ -10,8 +10,12 @@ import { TelemetryWithExp } from '../../completions-core/vscode-node/lib/src/tel
import { ICompletionsTextDocumentManagerService } from '../../completions-core/vscode-node/lib/src/textDocumentManager';
import { DocumentInfoWithOffset } from '../../completions-core/vscode-node/prompt/src/prompt';
import { getSimilarSnippets } from '../../completions-core/vscode-node/prompt/src/snippetInclusion/similarFiles';
+import { SnippetWithProviderInfo } from '../../completions-core/vscode-node/prompt/src/snippetInclusion/snippets';
import { ICopilotInlineCompletionItemProviderService } from '../../completions/common/copilotInlineCompletionItemProviderService';
-import { ISimilarFilesContextService } from '../../xtab/common/similarFilesContextService';
+import { LineRange0Based } from '../../xtab/common/lineRange';
+import { INeighborFileSnippet, ISimilarFilesContextService } from '../../xtab/common/similarFilesContextService';
+
+type RankedSnippet = SnippetWithProviderInfo & { relativePath?: string };
export class SimilarFilesContextService implements ISimilarFilesContextService {
@@ -23,39 +27,13 @@ export class SimilarFilesContextService implements ISimilarFilesContextService {
async compute(uri: string, languageId: string, source: string, cursorOffset: number): Promise {
try {
- const completionsInstaService = this._copilotService.getOrCreateInstantiationService();
- const telemetryData = TelemetryWithExp.createEmptyConfigForTesting();
-
- const { docs } = await completionsInstaService.invokeFunction(
- accessor => NeighborSource.getNeighborFilesAndTraits(accessor, uri, languageId, telemetryData)
- );
-
- const promptOptions = completionsInstaService.invokeFunction(getPromptOptions, telemetryData, languageId);
- const similarFilesOptions =
- promptOptions.similarFilesOptions ||
- completionsInstaService.invokeFunction(getSimilarFilesOptions, telemetryData, languageId);
-
- const tdm = completionsInstaService.invokeFunction(accessor => accessor.get(ICompletionsTextDocumentManagerService));
- const relativePath = tdm.getRelativePath({ uri });
-
- const docInfo: DocumentInfoWithOffset = {
- uri,
- source,
- languageId,
- offset: cursorOffset,
- relativePath,
- };
-
- const snippets = (await getSimilarSnippets(
- docInfo,
- Array.from(docs.values()),
- similarFilesOptions,
- ))
- .filter(s => s.snippet.length > 0)
- .sort((a, b) => a.score - b.score);
-
+ const result = await this._gatherSnippets(uri, languageId, source, cursorOffset);
+ if (!result) {
+ return undefined;
+ }
+ const { neighborFileCount, snippets } = result;
return JSON.stringify({
- neighborFileCount: docs.size,
+ neighborFileCount,
snippets: snippets.map(s => ({
score: s.score,
startLine: s.startLine,
@@ -68,4 +46,66 @@ export class SimilarFilesContextService implements ISimilarFilesContextService {
return undefined;
}
}
+
+ async getSnippetsForPrompt(uri: string, languageId: string, source: string, cursorOffset: number): Promise {
+ try {
+ const result = await this._gatherSnippets(uri, languageId, source, cursorOffset);
+ if (!result) {
+ return undefined;
+ }
+ const { snippets, relativePathToUri } = result;
+ return snippets.map(s => ({
+ uri: (s.relativePath && relativePathToUri.get(s.relativePath)) ?? uri,
+ relativePath: s.relativePath,
+ snippet: s.snippet,
+ lineRange: new LineRange0Based(s.startLine, s.endLine),
+ score: s.score,
+ }));
+ } catch {
+ return undefined;
+ }
+ }
+
+ private async _gatherSnippets(uri: string, languageId: string, source: string, cursorOffset: number): Promise<{ neighborFileCount: number; snippets: RankedSnippet[]; relativePathToUri: Map } | undefined> {
+ const completionsInstaService = this._copilotService.getOrCreateInstantiationService();
+ const telemetryData = TelemetryWithExp.createEmptyConfigForTesting();
+
+ const { docs } = await completionsInstaService.invokeFunction(
+ accessor => NeighborSource.getNeighborFilesAndTraits(accessor, uri, languageId, telemetryData)
+ );
+
+ const promptOptions = completionsInstaService.invokeFunction(getPromptOptions, telemetryData, languageId);
+ const similarFilesOptions =
+ promptOptions.similarFilesOptions ||
+ completionsInstaService.invokeFunction(getSimilarFilesOptions, telemetryData, languageId);
+
+ const tdm = completionsInstaService.invokeFunction(accessor => accessor.get(ICompletionsTextDocumentManagerService));
+ const relativePath = tdm.getRelativePath({ uri });
+
+ const docInfo: DocumentInfoWithOffset = {
+ uri,
+ source,
+ languageId,
+ offset: cursorOffset,
+ relativePath,
+ };
+
+ const neighborDocs = Array.from(docs.values());
+ const relativePathToUri = new Map();
+ for (const doc of neighborDocs) {
+ if (doc.relativePath) {
+ relativePathToUri.set(doc.relativePath, doc.uri);
+ }
+ }
+
+ const snippets = (await getSimilarSnippets(
+ docInfo,
+ neighborDocs,
+ similarFilesOptions,
+ ))
+ .filter(s => s.snippet.length > 0)
+ .sort((a, b) => a.score - b.score);
+
+ return { neighborFileCount: docs.size, snippets, relativePathToUri };
+ }
}
diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts
index 853eb96833503..96f947cfb9938 100644
--- a/extensions/copilot/src/extension/intents/node/agentIntent.ts
+++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts
@@ -448,6 +448,11 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I
this.logService.debug(`[Agent] rendering with budget=${safeBudget} (baseBudget: ${baseBudget}, toolTokens: ${toolTokens}, totalTools: ${tools?.length ?? 0}, toolSearchEnabled: ${toolSearchEnabled}), summarizationEnabled=${summarizationEnabled}`);
let result: RenderPromptResult;
+ // When the "last two messages" cache breakpoint strategy is enabled,
+ // suppress prompt-tsx and heuristic cache breakpoints — messagesApi.ts
+ // will place breakpoints on the last two merged messages instead.
+ const useLastTwoMessagesCacheBPs = isAnthropicFamily(this.endpoint)
+ && this.configurationService.getExperimentBasedConfig(ConfigKey.AnthropicCacheBreakpointsLastTwoMessages, this.expService);
const props: AgentPromptProps = {
endpoint,
promptContext: {
@@ -458,7 +463,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I
}
},
location: this.location,
- enableCacheBreakpoints: summarizationEnabled,
+ enableCacheBreakpoints: summarizationEnabled && !useLastTwoMessagesCacheBPs,
...this.extraPromptProps,
customizations: this._resolvedCustomizations
};
@@ -722,7 +727,9 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I
}
}
- addCacheBreakpoints(result.messages);
+ if (!useLastTwoMessagesCacheBPs) {
+ addCacheBreakpoints(result.messages);
+ }
if (this.request.command === 'error') {
// Should trigger a 400
diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts
index e7afea1809f0d..5948f64620bb4 100644
--- a/extensions/copilot/src/extension/test/node/services.ts
+++ b/extensions/copilot/src/extension/test/node/services.ts
@@ -10,6 +10,8 @@ import { IChatHookService } from '../../../platform/chat/common/chatHookService'
import { IChatMLFetcher } from '../../../platform/chat/common/chatMLFetcher';
import { ISessionTranscriptService, NullSessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';
import { MockChatMLFetcher } from '../../../platform/chat/test/common/mockChatMLFetcher';
+import { ISessionStore } from '../../../platform/chronicle/common/sessionStore';
+import { SessionStore } from '../../../platform/chronicle/node/sessionStore';
import { IDiffService } from '../../../platform/diff/common/diffService';
import { DiffServiceImpl } from '../../../platform/diff/node/diffServiceImpl';
import { EmbeddingType, IEmbeddingsComputer } from '../../../platform/embeddings/common/embeddingsComputer';
@@ -54,13 +56,13 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d
import { ILanguageModelServer } from '../../agents/node/langModelServer';
import { MockLanguageModelServer } from '../../agents/node/test/mockLanguageModelServer';
import { IClaudeRuntimeDataService } from '../../chatSessions/claude/common/claudeRuntimeDataService';
+import { IClaudeSessionStateService } from '../../chatSessions/claude/common/claudeSessionStateService';
import { IClaudeToolPermissionService } from '../../chatSessions/claude/common/claudeToolPermissionService';
import { ClaudeCodeModels, IClaudeCodeModels } from '../../chatSessions/claude/node/claudeCodeModels';
import { IClaudeCodeSdkService } from '../../chatSessions/claude/node/claudeCodeSdkService';
import { ClaudeRuntimeDataService } from '../../chatSessions/claude/node/claudeRuntimeDataService';
-import { IClaudePluginService } from '../../chatSessions/claude/node/claudeSkills';
-import { IClaudeSessionStateService } from '../../chatSessions/claude/common/claudeSessionStateService';
import { ClaudeSessionStateService } from '../../chatSessions/claude/node/claudeSessionStateService';
+import { IClaudePluginService } from '../../chatSessions/claude/node/claudeSkills';
import { MockClaudeCodeSdkService } from '../../chatSessions/claude/node/test/mockClaudeCodeSdkService';
import { MockClaudeToolPermissionService } from '../../chatSessions/claude/node/test/mockClaudeToolPermissionService';
import { CommandServiceImpl, ICommandService } from '../../commands/node/commandService';
@@ -84,9 +86,7 @@ import { ToolGroupingService } from '../../tools/common/virtualTools/toolGroupin
import '../../tools/node/allTools';
import { TestToolsService } from '../../tools/node/test/testToolsService';
import { TestToolEmbeddingsComputer } from '../../tools/test/node/virtualTools/testVirtualTools';
-import { ISimilarFilesContextService } from '../../xtab/common/similarFilesContextService';
-import { ISessionStore } from '../../../platform/chronicle/common/sessionStore';
-import { SessionStore } from '../../../platform/chronicle/node/sessionStore';
+import { ISimilarFilesContextService, NullSimilarFilesContextService } from '../../xtab/common/similarFilesContextService';
export interface ISimulationModelConfig {
chatModel?: string;
@@ -180,14 +180,6 @@ class NullClaudePluginService implements IClaudePluginService {
}
}
-class NullSimilarFilesContextService implements ISimilarFilesContextService {
- declare readonly _serviceBrand: undefined;
-
- async compute(): Promise {
- return undefined;
- }
-}
-
class NullChatHookService implements IChatHookService {
declare readonly _serviceBrand: undefined;
diff --git a/extensions/copilot/src/extension/xtab/common/lineRange.ts b/extensions/copilot/src/extension/xtab/common/lineRange.ts
new file mode 100644
index 0000000000000..495d93f1035bb
--- /dev/null
+++ b/extensions/copilot/src/extension/xtab/common/lineRange.ts
@@ -0,0 +1,16 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/**
+ * A 0-based line range where `startLine` is inclusive and `endLineExcl` is exclusive.
+ */
+export class LineRange0Based {
+ constructor(
+ /** 0-based, inclusive. */
+ readonly startLine: number,
+ /** 0-based, exclusive. */
+ readonly endLineExcl: number,
+ ) { }
+}
diff --git a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts
index cb734872293a5..314265d2d6f6a 100644
--- a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts
+++ b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts
@@ -17,7 +17,8 @@ import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRa
import { getEditDiffHistory } from './diffHistoryForPrompt';
import { LintErrors } from './lintErrors';
import { countTokensForLines, toUniquePath } from './promptCraftingUtils';
-import { getRecentCodeSnippets } from './recentFilesForPrompt';
+import { AppendNeighborFileSnippetsResult, getRecentCodeSnippets } from './recentFilesForPrompt';
+import { INeighborFileSnippet } from './similarFilesContextService';
import { PromptTags } from './tags';
import { CurrentDocument } from './xtabCurrentDocument';
@@ -35,6 +36,7 @@ export class PromptPieces {
public readonly lintErrors: LintErrors,
public readonly computeTokens: (s: string) => number,
public readonly opts: PromptOptions,
+ public readonly neighborSnippets?: readonly INeighborFileSnippet[],
) {
}
}
@@ -43,14 +45,15 @@ export interface UserPromptResult {
readonly prompt: string;
readonly nDiffsInPrompt: number;
readonly diffTokensInPrompt: number;
+ readonly neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined;
}
export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult {
- const { activeDoc, xtabHistory, taggedCurrentDocLines, areaAroundCodeToEdit, langCtx, aggressivenessLevel, lintErrors, computeTokens, opts } = promptPieces;
+ const { activeDoc, xtabHistory, taggedCurrentDocLines, areaAroundCodeToEdit, langCtx, aggressivenessLevel, lintErrors, computeTokens, opts, neighborSnippets } = promptPieces;
const currentFileContent = taggedCurrentDocLines.join('\n');
- const { codeSnippets: recentlyViewedCodeSnippets, documents: docsInPrompt } = getRecentCodeSnippets(activeDoc, xtabHistory, langCtx, computeTokens, opts);
+ const { codeSnippets: recentlyViewedCodeSnippets, documents: docsInPrompt, neighborSnippetsResult } = getRecentCodeSnippets(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets);
docsInPrompt.add(activeDoc.id); // Add active document to the set of documents in prompt
@@ -125,7 +128,7 @@ ${PromptTags.EDIT_HISTORY.end}`;
const trimmedPrompt = prompt.trim();
- return { prompt: trimmedPrompt, nDiffsInPrompt, diffTokensInPrompt };
+ return { prompt: trimmedPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult };
}
function wrapInBackticks(content: string) {
diff --git a/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts b/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts
index 902c3d3a2ad69..0684435ec3a7d 100644
--- a/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts
+++ b/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts
@@ -16,6 +16,7 @@ import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRa
import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';
import { expandRangeToPageRange } from './promptCrafting';
import { countTokensForLines, toUniquePath } from './promptCraftingUtils';
+import { INeighborFileSnippet } from './similarFilesContextService';
import { PromptTags } from './tags';
export function getRecentCodeSnippets(
@@ -24,7 +25,8 @@ export function getRecentCodeSnippets(
langCtx: LanguageContextResponse | undefined,
computeTokens: (code: string) => number,
opts: PromptOptions,
-): { codeSnippets: string; documents: Set } {
+ neighborSnippets?: readonly INeighborFileSnippet[],
+): { codeSnippets: string; documents: Set; neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined } {
const { includeViewedFiles, nDocuments, clippingStrategy } = opts.recentlyViewedDocuments;
@@ -44,9 +46,15 @@ export function getRecentCodeSnippets(
appendLanguageContextSnippets(langCtx, snippets, opts.languageContext.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers);
}
+ let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined;
+ if (opts.neighborFiles.enabled && neighborSnippets && neighborSnippets.length > 0) {
+ neighborSnippetsResult = appendNeighborFileSnippets(neighborSnippets, snippets, docsInPrompt, opts.neighborFiles.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers);
+ }
+
return {
codeSnippets: snippets.join('\n\n'),
documents: docsInPrompt,
+ neighborSnippetsResult,
};
}
@@ -319,6 +327,76 @@ function appendLanguageContextSnippets(
}
}
+/**
+ * Result of appending neighbor-file snippets, used for telemetry.
+ */
+export interface AppendNeighborFileSnippetsResult {
+ /** Total snippets considered (input array length). */
+ readonly nComputed: number;
+ /** Snippets actually included in the prompt. */
+ readonly nIncluded: number;
+ /**
+ * Original input indices (ascending) of snippets included in the prompt.
+ * E.g. `[3, 4, 6]` means snippets at original indices 3, 4 and 6 were included
+ * (snippet at index 5 was skipped — too large or duplicate doc — and snippets
+ * at 0, 1 and 2 were skipped because the budget ran out).
+ */
+ readonly includedIndices: readonly number[];
+}
+
+/**
+ * Append Completions-style neighbor-file snippets (Jaccard-ranked) to the snippets array.
+ *
+ * Snippets are pre-clipped by Completions (each is a fixed-window match around
+ * the cursor in a neighbor file) and arrive ordered with the highest-scoring
+ * snippet last. We select greedily from highest score downward so the best
+ * snippets reserve budget first, skipping any whose file is already represented
+ * in {@link docsInPrompt} (avoids duplicating recently-edited or recently-viewed
+ * files) and any snippet that would exceed the remaining token budget. Selected
+ * snippets are then appended in score-ascending order so the highest-scoring
+ * snippet ends up closest to the current file in the prompt.
+ */
+export function appendNeighborFileSnippets(
+ neighborSnippets: readonly INeighborFileSnippet[],
+ snippets: string[],
+ docsInPrompt: Set,
+ tokenBudget: number,
+ computeTokens: (code: string) => number,
+ includeLineNumbers: xtabPromptOptions.IncludeLineNumbersOption,
+): AppendNeighborFileSnippetsResult {
+ const selected: { snippet: INeighborFileSnippet; originalIndex: number }[] = [];
+ // Iterate from highest score (last) to lowest (first) so the best snippets reserve budget first.
+ for (let i = neighborSnippets.length - 1; i >= 0; i--) {
+ const neighborSnippet = neighborSnippets[i];
+ const documentId = DocumentId.create(neighborSnippet.uri);
+ if (docsInPrompt.has(documentId)) {
+ continue;
+ }
+ const potentialBudget = tokenBudget - computeTokens(neighborSnippet.snippet);
+ if (potentialBudget < 0) {
+ continue;
+ }
+ selected.push({ snippet: neighborSnippet, originalIndex: i });
+ docsInPrompt.add(documentId);
+ tokenBudget = potentialBudget;
+ }
+ // Reverse so the highest-scoring snippet is appended last (closest to the current file).
+ for (let i = selected.length - 1; i >= 0; i--) {
+ const neighborSnippet = selected[i].snippet;
+ snippets.push(formatCodeSnippet(
+ DocumentId.create(neighborSnippet.uri),
+ neighborSnippet.snippet.split(/\r?\n/),
+ { truncated: false, includeLineNumbers, startLineOffset: neighborSnippet.lineRange.startLine },
+ ));
+ }
+ const includedIndices = selected.map(s => s.originalIndex).sort((a, b) => a - b);
+ return {
+ nComputed: neighborSnippets.length,
+ nIncluded: selected.length,
+ includedIndices,
+ };
+}
+
/**
* Clip a file without visible ranges by taking pages from the start until budget is exhausted.
*
diff --git a/extensions/copilot/src/extension/xtab/common/similarFilesContextService.ts b/extensions/copilot/src/extension/xtab/common/similarFilesContextService.ts
index 4b7d3e05dfd4c..fd1bb28137205 100644
--- a/extensions/copilot/src/extension/xtab/common/similarFilesContextService.ts
+++ b/extensions/copilot/src/extension/xtab/common/similarFilesContextService.ts
@@ -4,6 +4,19 @@
*--------------------------------------------------------------------------------------------*/
import { createServiceIdentifier } from '../../../util/common/services';
+import { LineRange0Based } from './lineRange';
+
+/**
+ * A neighbor-file snippet selected via Jaccard similarity, ready to be
+ * embedded into the recently_viewed_code_snippets section of the prompt.
+ */
+export interface INeighborFileSnippet {
+ readonly uri: string;
+ readonly relativePath: string | undefined;
+ readonly snippet: string;
+ readonly lineRange: LineRange0Based;
+ readonly score: number;
+}
export interface ISimilarFilesContextService {
readonly _serviceBrand: undefined;
@@ -13,6 +26,24 @@ export interface ISimilarFilesContextService {
* @returns JSON-serialized telemetry payload, or `undefined` on any error. Never throws.
*/
compute(uri: string, languageId: string, source: string, cursorOffset: number): Promise;
+
+ /**
+ * Computes neighbor-file snippets (Jaccard-ranked) intended for inclusion in the prompt.
+ * @returns Snippets ordered with best (highest scores) last, or `undefined` on any error. Never throws.
+ */
+ getSnippetsForPrompt(uri: string, languageId: string, source: string, cursorOffset: number): Promise;
}
export const ISimilarFilesContextService = createServiceIdentifier('ISimilarFilesContextService');
+
+export class NullSimilarFilesContextService implements ISimilarFilesContextService {
+ declare readonly _serviceBrand: undefined;
+
+ async compute(): Promise {
+ return undefined;
+ }
+
+ async getSnippetsForPrompt(): Promise {
+ return undefined;
+ }
+}
diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts
index e5196bcc2669f..291cc330fdf77 100644
--- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts
+++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts
@@ -357,6 +357,20 @@ export class XtabProvider implements IStatelessNextEditProvider {
return new NoNextEditReason.GotCancelled('afterLanguageContextAwait');
}
+ const neighborSnippets = promptOptions.neighborFiles.enabled
+ ? await raceCancellation(
+ raceTimeout(
+ this.similarFilesContextService.getSnippetsForPrompt(activeDocument.id.uri, activeDocument.languageId, activeDocument.documentAfterEdits.value, currentDocument.cursorOffset),
+ delaySession.getDebounceTime()
+ ),
+ cancellationToken,
+ )
+ : undefined;
+
+ if (cancellationToken.isCancellationRequested) {
+ return new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait');
+ }
+
const lintErrors = new LintErrors(activeDocument.id, currentDocument, this.langDiagService, request.xtabEditHistory);
const promptPieces = new PromptPieces(
@@ -371,13 +385,19 @@ export class XtabProvider implements IStatelessNextEditProvider {
aggressivenessLevel,
lintErrors,
XtabProvider.computeTokens,
- promptOptions
+ promptOptions,
+ neighborSnippets,
);
- const { prompt: userPrompt, nDiffsInPrompt, diffTokensInPrompt } = getUserPrompt(promptPieces);
+ const { prompt: userPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult } = getUserPrompt(promptPieces);
telemetry.setNDiffsInPrompt(nDiffsInPrompt);
telemetry.setDiffTokensInPrompt(diffTokensInPrompt);
+ if (neighborSnippetsResult) {
+ telemetry.setNNeighborSnippetsComputed(neighborSnippetsResult.nComputed);
+ telemetry.setNNeighborSnippetsInPrompt(neighborSnippetsResult.nIncluded);
+ telemetry.setNeighborSnippetIndicesInPrompt(neighborSnippetsResult.includedIndices);
+ }
const responseFormat = xtabPromptOptions.ResponseFormat.fromPromptingStrategy(promptOptions.promptingStrategy);
@@ -1408,6 +1428,10 @@ export class XtabProvider implements IStatelessNextEditProvider {
maxTokens: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabLanguageContextMaxTokens, this.expService),
traitPosition: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabLanguageContextTraitsPosition, this.expService),
}),
+ neighborFiles: {
+ enabled: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabIncludeNeighborFiles, this.expService),
+ maxTokens: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabNeighborFilesMaxTokens, this.expService),
+ },
diffHistory: {
nEntries: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabDiffNEntries, this.expService),
maxTokens: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabDiffMaxTokens, this.expService),
diff --git a/extensions/copilot/src/extension/xtab/test/common/recentFilesForPrompt.spec.ts b/extensions/copilot/src/extension/xtab/test/common/recentFilesForPrompt.spec.ts
index 00d4c588fb669..10a54c40a39fe 100644
--- a/extensions/copilot/src/extension/xtab/test/common/recentFilesForPrompt.spec.ts
+++ b/extensions/copilot/src/extension/xtab/test/common/recentFilesForPrompt.spec.ts
@@ -12,7 +12,9 @@ import { splitLines } from '../../../../util/vs/base/common/strings';
import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringEdit';
import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';
import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText';
-import { buildCodeSnippetsUsingPagedClipping, computeFocalPageCost, historyEntriesToCodeSnippet, selectFocalRangesWithinSpanCap } from '../../common/recentFilesForPrompt';
+import { LineRange0Based } from '../../common/lineRange';
+import { appendNeighborFileSnippets, buildCodeSnippetsUsingPagedClipping, computeFocalPageCost, historyEntriesToCodeSnippet, selectFocalRangesWithinSpanCap } from '../../common/recentFilesForPrompt';
+import { INeighborFileSnippet } from '../../common/similarFilesContextService';
function nLines(n: number): StringText {
return new StringText(new Array(n).fill(0).map((_, i) => `${i + 1}`).join('\n'));
@@ -825,3 +827,146 @@ suite('historyEntriesToCodeSnippet', () => {
]);
});
});
+
+suite('appendNeighborFileSnippets', () => {
+
+ function makeNeighbor(uri: string, snippet: string, startLine: number, score = 0.5): INeighborFileSnippet {
+ const lineCount = snippet.split(/\r?\n/).length;
+ return { uri, relativePath: uri, snippet, lineRange: new LineRange0Based(startLine, startLine + lineCount), score };
+ }
+
+ test('appends snippets formatted like recent files', () => {
+ const snippets: string[] = [];
+ const docsInPrompt = new Set();
+ appendNeighborFileSnippets(
+ [makeNeighbor('file:///src/n1.ts', 'a\nb', 5)],
+ snippets,
+ docsInPrompt,
+ /*tokenBudget*/ 100,
+ computeTokens,
+ IncludeLineNumbersOption.WithSpaceAfter,
+ );
+ expect(snippets).toMatchInlineSnapshot(`
+ [
+ "<|recently_viewed_code_snippet|>
+ code_snippet_file_path: /src/n1.ts
+ 5| a
+ 6| b
+ <|/recently_viewed_code_snippet|>",
+ ]
+ `);
+ expect(docsInPrompt.has(DocumentId.create('file:///src/n1.ts'))).toBe(true);
+ });
+
+ test('skips oversize snippets but keeps trying smaller ones', () => {
+ const snippets: string[] = [];
+ const docsInPrompt = new Set();
+ // First snippet ~= 8 tokens (32 chars / 4); second ~= 1 token. Budget 5 only allows the second.
+ appendNeighborFileSnippets(
+ [
+ makeNeighbor('file:///src/big.ts', 'b'.repeat(32), 0),
+ makeNeighbor('file:///src/small.ts', 'aa', 0),
+ ],
+ snippets,
+ docsInPrompt,
+ /*tokenBudget*/ 5,
+ computeTokens,
+ IncludeLineNumbersOption.None,
+ );
+ expect(snippets).toHaveLength(1);
+ expect(snippets[0]).toContain('/src/small.ts');
+ });
+
+ test('skips snippets whose document is already in the prompt', () => {
+ const dupDoc = DocumentId.create('file:///src/dup.ts');
+ const docsInPrompt = new Set([dupDoc]);
+ const snippets: string[] = [];
+ appendNeighborFileSnippets(
+ [
+ makeNeighbor('file:///src/dup.ts', 'duplicated', 0),
+ makeNeighbor('file:///src/fresh.ts', 'fresh', 0),
+ ],
+ snippets,
+ docsInPrompt,
+ /*tokenBudget*/ 100,
+ computeTokens,
+ IncludeLineNumbersOption.None,
+ );
+ expect(snippets).toHaveLength(1);
+ expect(snippets[0]).toContain('/src/fresh.ts');
+ });
+
+ test('no-op when no snippets provided', () => {
+ const snippets: string[] = [];
+ const docsInPrompt = new Set();
+ const result = appendNeighborFileSnippets([], snippets, docsInPrompt, 100, computeTokens, IncludeLineNumbersOption.None);
+ expect(snippets).toEqual([]);
+ expect(result).toEqual({ nComputed: 0, nIncluded: 0, includedIndices: [] });
+ });
+
+ test('selects highest-score snippets first when budget is tight', () => {
+ const snippets: string[] = [];
+ const docsInPrompt = new Set();
+ // Each snippet ~= 8 tokens (32 chars / 4); budget 10 only fits one.
+ // The highest-score one (last in input) must win.
+ appendNeighborFileSnippets(
+ [
+ makeNeighbor('file:///src/low.ts', 'l'.repeat(32), 0, /*score*/ 0.1),
+ makeNeighbor('file:///src/high.ts', 'h'.repeat(32), 0, /*score*/ 0.9),
+ ],
+ snippets,
+ docsInPrompt,
+ /*tokenBudget*/ 10,
+ computeTokens,
+ IncludeLineNumbersOption.None,
+ );
+ expect(snippets).toHaveLength(1);
+ expect(snippets[0]).toContain('/src/high.ts');
+ });
+
+ test('places highest-score snippet last (closest to current file)', () => {
+ const snippets: string[] = [];
+ const docsInPrompt = new Set();
+ appendNeighborFileSnippets(
+ [
+ makeNeighbor('file:///src/low.ts', 'low', 0, /*score*/ 0.1),
+ makeNeighbor('file:///src/mid.ts', 'mid', 0, /*score*/ 0.5),
+ makeNeighbor('file:///src/high.ts', 'high', 0, /*score*/ 0.9),
+ ],
+ snippets,
+ docsInPrompt,
+ /*tokenBudget*/ 100,
+ computeTokens,
+ IncludeLineNumbersOption.None,
+ );
+ expect(snippets).toHaveLength(3);
+ expect(snippets[0]).toContain('/src/low.ts');
+ expect(snippets[1]).toContain('/src/mid.ts');
+ expect(snippets[2]).toContain('/src/high.ts');
+ });
+
+ test('reports included indices, skipping oversize and budget-exhausted entries', () => {
+ // Indices: 0 (small low score), 1 (small low score), 2 (small low score), 3 (small mid), 4 (small mid), 5 (huge), 6 (small high)
+ // Budget allows ~4 small snippets (each ~1 token, big takes ~16 tokens, budget=4).
+ // Iteration order: 6, 5 (skipped — too big), 4, 3, 2, 1 (skipped — out of budget), 0 (skipped — out of budget).
+ const snippets: string[] = [];
+ const docsInPrompt = new Set();
+ const result = appendNeighborFileSnippets(
+ [
+ makeNeighbor('file:///src/i0.ts', 'a', 0, /*score*/ 0.1),
+ makeNeighbor('file:///src/i1.ts', 'b', 0, /*score*/ 0.2),
+ makeNeighbor('file:///src/i2.ts', 'c', 0, /*score*/ 0.3),
+ makeNeighbor('file:///src/i3.ts', 'd', 0, /*score*/ 0.4),
+ makeNeighbor('file:///src/i4.ts', 'e', 0, /*score*/ 0.5),
+ makeNeighbor('file:///src/i5.ts', 'h'.repeat(64), 0, /*score*/ 0.6),
+ makeNeighbor('file:///src/i6.ts', 'g', 0, /*score*/ 0.9),
+ ],
+ snippets,
+ docsInPrompt,
+ /*tokenBudget*/ 4,
+ computeTokens,
+ IncludeLineNumbersOption.None,
+ );
+ expect(result).toEqual({ nComputed: 7, nIncluded: 4, includedIndices: [2, 3, 4, 6] });
+ });
+});
diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts
index 4dd64f67f70b0..2ed011e55bc69 100644
--- a/extensions/copilot/src/lib/node/chatLibMain.ts
+++ b/extensions/copilot/src/lib/node/chatLibMain.ts
@@ -55,7 +55,7 @@ import { LlmNESTelemetryBuilder, NextEditProviderTelemetryBuilder, TelemetrySend
import { INextEditResult } from '../../extension/inlineEdits/node/nextEditResult';
import { IPowerService, NullPowerService } from '../../extension/power/common/powerService';
import { ChatMLFetcherImpl } from '../../extension/prompt/node/chatMLFetcher';
-import { ISimilarFilesContextService } from '../../extension/xtab/common/similarFilesContextService';
+import { ISimilarFilesContextService, NullSimilarFilesContextService } from '../../extension/xtab/common/similarFilesContextService';
import { XtabProvider } from '../../extension/xtab/node/xtabProvider';
import { IAuthenticationService } from '../../platform/authentication/common/authentication';
import { ICopilotTokenManager } from '../../platform/authentication/common/copilotTokenManager';
@@ -104,8 +104,8 @@ import { resolveOTelConfig } from '../../platform/otel/common/otelConfig';
import { IOTelService } from '../../platform/otel/common/otelService';
import { IProxyModelsService } from '../../platform/proxyModels/common/proxyModelsService';
import { ProxyModelsService } from '../../platform/proxyModels/node/proxyModelsService';
-import { NullRequestLogger } from '../../platform/requestLogger/node/nullRequestLogger';
import { IRequestLogger } from '../../platform/requestLogger/common/requestLogger';
+import { NullRequestLogger } from '../../platform/requestLogger/node/nullRequestLogger';
import { ISimulationTestContext, NulSimulationTestContext } from '../../platform/simulationTestContext/common/simulationTestContext';
import { ISnippyService, NullSnippyService } from '../../platform/snippy/common/snippyService';
import { IExperimentationService, TreatmentsChangeEvent } from '../../platform/telemetry/common/nullExperimentationService';
@@ -484,14 +484,6 @@ class OverridableConfigurationService extends DefaultsOnlyConfigurationService {
}
}
-class NullSimilarFilesContextService implements ISimilarFilesContextService {
- declare readonly _serviceBrand: undefined;
-
- async compute(): Promise {
- return undefined;
- }
-}
-
class NullEndpointProvider implements IEndpointProvider {
declare readonly _serviceBrand: undefined;
readonly onDidModelsRefresh = VsEvent.None;
diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts
index e50b1be311b76..7c7033a50da56 100644
--- a/extensions/copilot/src/platform/configuration/common/configurationService.ts
+++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts
@@ -832,6 +832,8 @@ export namespace ConfigKey {
export const InlineEditsXtabEarlyCursorLineDivergenceCancellation = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.earlyCursorLineDivergenceCancellation', ConfigType.ExperimentBased, EarlyDivergenceCancellationMode.Off, EarlyDivergenceCancellationMode.VALIDATOR);
export const InlineEditsXtabLanguageContextEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabled', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.enabled);
export const InlineEditsXtabLanguageContextMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.maxTokens);
+ export const InlineEditsXtabIncludeNeighborFiles = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.neighborFiles.enabled', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.neighborFiles.enabled);
+ export const InlineEditsXtabNeighborFilesMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.neighborFiles.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.neighborFiles.maxTokens);
export const InlineEditsXtabMaxMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.maxMergeConflictLines', ConfigType.ExperimentBased, undefined);
export const InlineEditsXtabOnlyMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.onlyMergeConflictLines', ConfigType.ExperimentBased, false);
export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, undefined);
@@ -907,6 +909,8 @@ export namespace ConfigKey {
/** Use the Messages API instead of Chat Completions when supported */
export const UseAnthropicMessagesApi = defineSetting('chat.anthropic.useMessagesApi', ConfigType.ExperimentBased, true);
+ /** Use "last two messages" cache breakpoint strategy instead of heuristic-based placement */
+ export const AnthropicCacheBreakpointsLastTwoMessages = defineSetting('chat.anthropic.cacheBreakpoints.lastTwoMessages', ConfigType.ExperimentBased, false);
/** Context editing mode for Anthropic Messages API. 'off' disables context editing. */
export const AnthropicContextEditingMode = defineSetting<'off' | 'clear-thinking' | 'clear-tooluse' | 'clear-both'>('chat.anthropic.contextEditing.mode', ConfigType.ExperimentBased, 'off');
/** Configure reasoning summary style sent to Responses API */
diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts
index fc3c6469535bb..5d7fb7613a75a 100644
--- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts
+++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts
@@ -181,11 +181,25 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I
const validToolNames = finalTools.length > 0 ? new Set(finalTools.map(t => t.name)) : undefined;
const messagesResult = rawMessagesToMessagesAPI(options.messages, toolSearchEnabled ? validToolNames : undefined);
+ // "Last two messages" cache breakpoint strategy: place cache_control on the last
+ // two merged messages. This is gated behind an experiment and replaces the
+ // heuristic-based addCacheBreakpoints (which runs upstream in the prompt builder).
+ // Run before addToolsAndSystemCacheControl: shifting markers placed first,
+ // static markers fill the remainder. When the experiment is on we count slots
+ // once and thread the budget through both functions to avoid a redundant walk.
+ const useLastTwoMessages = configurationService.getExperimentBasedConfig(ConfigKey.AnthropicCacheBreakpointsLastTwoMessages, experimentationService);
+ let precomputedSlots: number | undefined;
+ if (useLastTwoMessages) {
+ precomputedSlots = maxCacheBreakpoints - countExistingMessageAndSystemCacheControl(messagesResult);
+ if (precomputedSlots > 0) {
+ precomputedSlots -= addLastTwoMessagesCacheControl(messagesResult, precomputedSlots);
+ }
+ }
+
// Add cache_control to the last tool and last system block so the stable tools+system
// prefix is cached across turns. Per the Anthropic docs, cache prefixes are created in
// order: tools → system → messages, and a max of 4 cache_control blocks is allowed.
- // Count existing cache_control in messages+system first to stay within the limit.
- addToolsAndSystemCacheControl(finalTools, messagesResult);
+ addToolsAndSystemCacheControl(finalTools, messagesResult, precomputedSlots);
// Guard: The Anthropic Messages API requires the conversation to end with a user message.
// A trailing assistant message is treated as a prefill request, which is not supported
@@ -478,23 +492,16 @@ function contentBlockSupportsCacheControl(block: ContentBlockParam): block is Ex
const maxCacheBreakpoints = 4;
/**
- * Optionally adds cache_control to the tools and system prefix when there are spare
- * slots available (i.e. existing breakpoints < max). The last non-deferred tool is
- * marked first if possible, and the last system block is marked only while slots remain.
- * Message-level cache breakpoints are never evicted because they already implicitly
- * cache the tools+system prefix (Anthropic cache hierarchy: tools → system → messages)
- * and cover more content.
+ * Counts existing cache_control markers across system blocks and messages.
+ * Does not count tool-level cache_control — tools are managed separately by
+ * addToolsAndSystemCacheControl.
*/
-export function addToolsAndSystemCacheControl(
- tools: AnthropicMessagesTool[],
- messagesResult: { messages: MessageParam[]; system?: TextBlockParam[] },
-): void {
- // Count existing cache_control in messages and system
- let existingCount = 0;
+function countExistingMessageAndSystemCacheControl(messagesResult: { messages: MessageParam[]; system?: TextBlockParam[] }): number {
+ let count = 0;
if (messagesResult.system) {
for (const block of messagesResult.system) {
if (block.cache_control) {
- existingCount++;
+ count++;
}
}
}
@@ -502,13 +509,30 @@ export function addToolsAndSystemCacheControl(
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (typeof block === 'object' && 'cache_control' in block && block.cache_control) {
- existingCount++;
+ count++;
}
}
}
}
+ return count;
+}
- let slotsAvailable = maxCacheBreakpoints - existingCount;
+/**
+ * Optionally adds cache_control to the tools and system prefix when there are spare
+ * slots available (i.e. existing breakpoints < max). The last non-deferred tool is
+ * marked first if possible, and the last system block is marked only while slots remain.
+ * Message-level cache breakpoints are never evicted because they already implicitly
+ * cache the tools+system prefix (Anthropic cache hierarchy: tools → system → messages)
+ * and cover more content.
+ */
+export function addToolsAndSystemCacheControl(
+ tools: AnthropicMessagesTool[],
+ messagesResult: { messages: MessageParam[]; system?: TextBlockParam[] },
+ slotsAvailable?: number,
+): void {
+ if (slotsAvailable === undefined) {
+ slotsAvailable = maxCacheBreakpoints - countExistingMessageAndSystemCacheControl(messagesResult);
+ }
if (slotsAvailable <= 0) {
return;
}
@@ -534,6 +558,65 @@ export function addToolsAndSystemCacheControl(
}
}
+/**
+ * Adds cache_control to the last two distinct messages in the conversation.
+ * This implements a simpler "shifting breakpoint" strategy: the last two messages
+ * always carry cache breakpoints, which naturally advances as the conversation grows.
+ * Combined with addToolsAndSystemCacheControl (which handles tools + system),
+ * this gives 4 breakpoints: 2 static (tools/system) + 2 shifting (last two messages).
+ *
+ * If a trailing message already carries a cache_control marker, it counts toward the
+ * "two distinct messages" target and no additional marker is added — protecting the
+ * intent against any upstream code that may have placed markers before this runs.
+ *
+ * Returns the number of new cache_control markers added (0–2).
+ */
+export function addLastTwoMessagesCacheControl(
+ messagesResult: { messages: MessageParam[]; system?: TextBlockParam[] },
+ slotsAvailable?: number,
+): number {
+ if (slotsAvailable === undefined) {
+ slotsAvailable = maxCacheBreakpoints - countExistingMessageAndSystemCacheControl(messagesResult);
+ }
+ if (slotsAvailable <= 0) {
+ return 0;
+ }
+
+ // Walk messages in reverse, marking the last cacheable content block of the
+ // last two distinct messages. A message that already has a cache_control
+ // marker counts toward the target without a new marker being added.
+ const messages = messagesResult.messages;
+ let markedCount = 0;
+ let added = 0;
+ for (let i = messages.length - 1; i >= 0 && slotsAvailable > 0 && markedCount < 2; i--) {
+ const msg = messages[i];
+ if (!Array.isArray(msg.content) || msg.content.length === 0) {
+ continue;
+ }
+
+ const alreadyMarked = msg.content.some(b =>
+ typeof b === 'object' && 'cache_control' in b && b.cache_control
+ );
+ if (alreadyMarked) {
+ markedCount++;
+ continue;
+ }
+
+ // Find the last block in this message that supports cache_control
+ for (let j = msg.content.length - 1; j >= 0; j--) {
+ const block = msg.content[j];
+ if (typeof block === 'object' && contentBlockSupportsCacheControl(block)) {
+ block.cache_control = { type: 'ephemeral' };
+ slotsAvailable--;
+ markedCount++;
+ added++;
+ break;
+ }
+ }
+ }
+ return added;
+}
+
export async function processResponseFromMessagesEndpoint(
instantiationService: IInstantiationService,
telemetryService: ITelemetryService,
diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
index 798481a0466d5..787d79bc37d40 100644
--- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
+++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
@@ -15,7 +15,7 @@ import { AnthropicMessagesTool, CUSTOM_TOOL_SEARCH_NAME } from '../../../network
import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking';
import { IToolDeferralService } from '../../../networking/common/toolDeferralService';
import { createPlatformServices } from '../../../test/node/services';
-import { addToolsAndSystemCacheControl, buildToolInputSchema, createMessagesRequestBody, rawMessagesToMessagesAPI } from '../../node/messagesApi';
+import { addLastTwoMessagesCacheControl, addToolsAndSystemCacheControl, buildToolInputSchema, createMessagesRequestBody, rawMessagesToMessagesAPI } from '../../node/messagesApi';
function assertContentArray(content: MessageParam['content']): ContentBlockParam[] {
expect(Array.isArray(content)).toBe(true);
@@ -665,6 +665,333 @@ suite('buildToolInputSchema', function () {
});
});
+suite('addLastTwoMessagesCacheControl', function () {
+
+ function makeMessages(...msgs: MessageParam[]): MessageParam[] {
+ return msgs;
+ }
+
+ function makeTool(name: string, deferred = false): AnthropicMessagesTool {
+ return {
+ name,
+ description: `${name} tool`,
+ input_schema: { type: 'object', properties: {}, required: [] },
+ ...(deferred ? { defer_loading: true } : {}),
+ };
+ }
+
+ function getCacheControl(block: ContentBlockParam): { type: string } | undefined {
+ return 'cache_control' in block ? (block as { cache_control?: { type: string } }).cache_control : undefined;
+ }
+
+ function countAllCacheControl(messages: MessageParam[], system?: TextBlockParam[]): number {
+ let count = 0;
+ if (system) {
+ for (const block of system) {
+ if (block.cache_control) {
+ count++;
+ }
+ }
+ }
+ for (const msg of messages) {
+ if (Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (typeof block === 'object' && 'cache_control' in block && block.cache_control) {
+ count++;
+ }
+ }
+ }
+ }
+ return count;
+ }
+
+ test('marks last two messages in a normal agentic loop', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'edit my file' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'calling tool' }, { type: 'tool_use', id: 'toolu_1', name: 'read_file', input: {} }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'file contents' }] }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ const assistantContent = messages[1].content as ContentBlockParam[];
+ expect(getCacheControl(assistantContent[assistantContent.length - 1])).toEqual({ type: 'ephemeral' });
+
+ const toolResult = (messages[2].content as ContentBlockParam[])[0] as ToolResultBlockParam;
+ expect(toolResult.cache_control).toEqual({ type: 'ephemeral' });
+
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toBeUndefined();
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('marks last two messages in plain chat', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'hi there' }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('handles single message', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(1);
+ });
+
+ test('handles empty messages array', function () {
+ const messagesResult = { messages: [] as MessageParam[] };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ expect(messagesResult.messages).toHaveLength(0);
+ });
+
+ test('skips thinking and redacted_thinking blocks', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
+ {
+ role: 'assistant', content: [
+ { type: 'thinking', thinking: 'hmm', signature: 'sig' },
+ { type: 'text', text: 'response' },
+ ] as ContentBlockParam[]
+ },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ const assistantContent = messages[1].content as ContentBlockParam[];
+ expect(getCacheControl(assistantContent[0])).toBeUndefined();
+ expect(getCacheControl(assistantContent[1])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('respects max breakpoint count when some already exist', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'd' }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'e' }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ // 3 existing + 1 new = 4 total
+ expect(countAllCacheControl(messages)).toBe(4);
+ expect(getCacheControl((messages[4].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ // Second-to-last should NOT get one — would exceed 4
+ expect(getCacheControl((messages[3].content as ContentBlockParam[])[0])).toBeUndefined();
+ });
+
+ test('does nothing when all 4 slots are occupied', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'e' }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ expect(getCacheControl((messages[4].content as ContentBlockParam[])[0])).toBeUndefined();
+ expect(countAllCacheControl(messages)).toBe(4);
+ });
+
+ test('treats trailing message with existing cache_control as already marked', function () {
+ // Regression: prior code would walk past a pre-marked tail message and
+ // add two new markers to earlier messages, ending up with 3 distinct
+ // marked messages instead of 2.
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'b' }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'c' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ const added = addLastTwoMessagesCacheControl(messagesResult);
+
+ expect(added).toBe(1);
+ expect(getCacheControl((messages[3].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toBeUndefined();
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toBeUndefined();
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('does not add a second marker to a message that already has one on a non-last block', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },
+ {
+ role: 'assistant', content: [
+ { type: 'text', text: 'first', cache_control: { type: 'ephemeral' } },
+ { type: 'text', text: 'second' },
+ ] as ContentBlockParam[]
+ },
+ );
+ const messagesResult = { messages };
+
+ const added = addLastTwoMessagesCacheControl(messagesResult);
+
+ // Last message already counts as marked; only the prior message gets a new marker.
+ expect(added).toBe(1);
+ const assistantContent = messages[1].content as ContentBlockParam[];
+ expect(getCacheControl(assistantContent[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl(assistantContent[1])).toBeUndefined();
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('marks assistant-with-tool-calls as fork point', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'do stuff' }] as ContentBlockParam[] },
+ {
+ role: 'assistant', content: [
+ { type: 'text', text: 'I will call tools' },
+ { type: 'tool_use', id: 'toolu_a', name: 'tool_a', input: {} },
+ { type: 'tool_use', id: 'toolu_b', name: 'tool_b', input: {} },
+ ] as ContentBlockParam[]
+ },
+ {
+ role: 'user', content: [
+ { type: 'tool_result', tool_use_id: 'toolu_a', content: [{ type: 'text', text: 'result a' }] },
+ { type: 'tool_result', tool_use_id: 'toolu_b', content: [{ type: 'text', text: 'result b' }] },
+ ] as ContentBlockParam[]
+ },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ const assistantContent = messages[1].content as ContentBlockParam[];
+ expect(getCacheControl(assistantContent[2])).toEqual({ type: 'ephemeral' });
+
+ const userContent = messages[2].content as ContentBlockParam[];
+ expect(getCacheControl(userContent[1])).toEqual({ type: 'ephemeral' });
+
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('counts system block breakpoints toward the limit', function () {
+ const system: TextBlockParam[] = [
+ { type: 'text', text: 'system', cache_control: { type: 'ephemeral' } },
+ ];
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'b' }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'c' }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages, system };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ // 1 system + 2 message breakpoints = 3 total
+ expect(countAllCacheControl(messages, system)).toBe(3);
+ expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ });
+
+ test('skips tail message with empty content and marks two prior', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'response' }] as ContentBlockParam[] },
+ { role: 'user', content: [] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('skips thinking-only tail message and marks two prior', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'first response' }] as ContentBlockParam[] },
+ {
+ role: 'assistant', content: [
+ { type: 'thinking', thinking: 'deep thought', signature: 'sig' },
+ { type: 'redacted_thinking', data: 'redacted' },
+ ] as ContentBlockParam[]
+ },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ // Thinking-only message has no cacheable blocks — skip it
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('skips empty middle message and still finds two cacheable', function () {
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'text', text: 'follow up' }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages };
+
+ addLastTwoMessagesCacheControl(messagesResult);
+
+ // Last message + first message (middle is empty, skipped)
+ expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
+ expect(countAllCacheControl(messages)).toBe(2);
+ });
+
+ test('round-trip with addToolsAndSystemCacheControl produces exactly 4 markers', function () {
+ const tools = [makeTool('read_file'), makeTool('edit_file')];
+ const system: TextBlockParam[] = [{ type: 'text', text: 'You are a helpful assistant.' }];
+ const messages = makeMessages(
+ { role: 'user', content: [{ type: 'text', text: 'edit my file' }] as ContentBlockParam[] },
+ { role: 'assistant', content: [{ type: 'text', text: 'calling tool' }, { type: 'tool_use', id: 'toolu_1', name: 'read_file', input: {} }] as ContentBlockParam[] },
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'file contents' }] }] as ContentBlockParam[] },
+ );
+ const messagesResult = { messages, system };
+
+ // Call both in the same order as createMessagesRequestBody
+ addLastTwoMessagesCacheControl(messagesResult);
+ addToolsAndSystemCacheControl(tools, messagesResult);
+
+ // 2 message breakpoints + 1 tool + 1 system = 4
+ let totalCount = countAllCacheControl(messages, system);
+ for (const tool of tools) {
+ if (tool.cache_control) {
+ totalCount++;
+ }
+ }
+ expect(totalCount).toBe(4);
+
+ // Verify positions
+ const assistantContent = messages[1].content as ContentBlockParam[];
+ expect(getCacheControl(assistantContent[assistantContent.length - 1])).toEqual({ type: 'ephemeral' });
+ expect(((messages[2].content as ContentBlockParam[])[0] as ToolResultBlockParam).cache_control).toEqual({ type: 'ephemeral' });
+ expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });
+ expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
+ });
+});
+
describe('createMessagesRequestBody reasoning effort', () => {
let disposables: DisposableStore;
let instantiationService: IInstantiationService;
diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts
index 0dee4e8cf4d98..c7811693883cd 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts
@@ -51,6 +51,22 @@ export type LanguageContextOptions = {
readonly traitPosition: 'before' | 'after';
};
+/**
+ * Options for including Completions-style neighbor file snippets (Jaccard-ranked)
+ * into the recently_viewed_code_snippets section of the prompt.
+ */
+export type NeighborFilesOptions = {
+ readonly enabled: boolean;
+ readonly maxTokens: number;
+};
+
+export namespace NeighborFilesOptions {
+ export const VALIDATOR: IValidator> = vObj({
+ 'enabled': vBoolean(),
+ 'maxTokens': vNumber(),
+ });
+}
+
export type DiffHistoryOptions = {
readonly nEntries: number;
readonly maxTokens: number;
@@ -238,6 +254,7 @@ export type PromptOptions = {
readonly pagedClipping: PagedClipping;
readonly recentlyViewedDocuments: RecentlyViewedDocumentsOptions;
readonly languageContext: LanguageContextOptions;
+ readonly neighborFiles: NeighborFilesOptions;
readonly diffHistory: DiffHistoryOptions;
readonly includePostScript: boolean;
readonly lintOptions: LintOptions | undefined;
@@ -351,6 +368,10 @@ export const DEFAULT_OPTIONS: PromptOptions = {
maxTokens: 2000,
traitPosition: 'after',
},
+ neighborFiles: {
+ enabled: false,
+ maxTokens: 1000,
+ },
diffHistory: {
nEntries: 25,
maxTokens: 1000,
diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts
index 3e27f7701fae3..f43503bab05f6 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts
@@ -414,6 +414,12 @@ export interface IStatelessNextEditTelemetry {
readonly nDiffsInPrompt: number | undefined;
readonly diffTokensInPrompt: number | undefined;
+ /* neighbor (similar files) snippets info */
+ readonly nNeighborSnippetsComputed: number | undefined;
+ readonly nNeighborSnippetsInPrompt: number | undefined;
+ /** JSON-encoded array of original input indices of snippets included in the prompt. */
+ readonly neighborSnippetIndicesInPrompt: string | undefined;
+
/* lint errors info */
readonly lintErrors: string | undefined;
@@ -506,6 +512,9 @@ export class StatelessNextEditTelemetryBuilder {
cursorJumpResponse: this._cursorJumpResponse,
nDiffsInPrompt: this._nDiffsInPrompt,
diffTokensInPrompt: this._diffTokensInPrompt,
+ nNeighborSnippetsComputed: this._nNeighborSnippetsComputed,
+ nNeighborSnippetsInPrompt: this._nNeighborSnippetsInPrompt,
+ neighborSnippetIndicesInPrompt: this._neighborSnippetIndicesInPrompt,
lintErrors: this._lintErrors,
terminalOutput: this._terminalOutput,
similarFilesContext: this._similarFilesContext,
@@ -703,6 +712,24 @@ export class StatelessNextEditTelemetryBuilder {
return this;
}
+ private _nNeighborSnippetsComputed: number | undefined;
+ public setNNeighborSnippetsComputed(n: number): this {
+ this._nNeighborSnippetsComputed = n;
+ return this;
+ }
+
+ private _nNeighborSnippetsInPrompt: number | undefined;
+ public setNNeighborSnippetsInPrompt(n: number): this {
+ this._nNeighborSnippetsInPrompt = n;
+ return this;
+ }
+
+ private _neighborSnippetIndicesInPrompt: string | undefined;
+ public setNeighborSnippetIndicesInPrompt(indices: readonly number[]): this {
+ this._neighborSnippetIndicesInPrompt = JSON.stringify(indices);
+ return this;
+ }
+
private _lintErrors: string | undefined;
public setLintErrors(lintErrors: string): this {
this._lintErrors = lintErrors;
diff --git a/package-lock.json b/package-lock.json
index 477c2579e7626..884d9d05c71c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -171,6 +171,7 @@
"yaserver": "^0.4.0"
},
"optionalDependencies": {
+ "@vscode/macos-keychain": "^0.0.1",
"windows-foreground-love": "0.6.1"
}
},
@@ -3811,6 +3812,31 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/@vscode/macos-keychain": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@vscode/macos-keychain/-/macos-keychain-0.0.1.tgz",
+ "integrity": "sha512-8R5eKUZRoRUJvmoKgPrXFlEpBg6n8XKq0jyA85DLDuO1ZMbGuKsu2KsUCl7jWm06+h0ajZXUF0Z7dkk6j4IguA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "node-addon-api": "^8.2.0"
+ }
+ },
+ "node_modules/@vscode/macos-keychain/node_modules/node-addon-api": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
+ "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
"node_modules/@vscode/native-watchdog": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@vscode/native-watchdog/-/native-watchdog-1.4.6.tgz",
diff --git a/package.json b/package.json
index 829ab071f3f2f..0ddf8d4a732ae 100644
--- a/package.json
+++ b/package.json
@@ -267,6 +267,7 @@
"url": "https://github.com/microsoft/vscode/issues"
},
"optionalDependencies": {
+ "@vscode/macos-keychain": "^0.0.1",
"windows-foreground-love": "0.6.1"
}
}
diff --git a/product.json b/product.json
index 3b7b76b5ca315..b8c702d3dbd7a 100644
--- a/product.json
+++ b/product.json
@@ -26,6 +26,7 @@
"win32TunnelServiceMutex": "vscodeoss-tunnelservice",
"win32TunnelMutex": "vscodeoss-tunnel",
"darwinBundleIdentifier": "com.visualstudio.code.oss",
+ "darwinSharedKeychainServiceName": "com.visualstudio.code.oss.shared-secrets",
"darwinProfileUUID": "47827DD9-4734-49A0-AF80-7E19B11495CC",
"darwinProfilePayloadUUID": "CF808BE7-53F3-46C6-A7E2-7EDB98A5E959",
"linuxIconName": "code-oss",
diff --git a/src/typings/macos-keychain.d.ts b/src/typings/macos-keychain.d.ts
new file mode 100644
index 0000000000000..d10bdab322e19
--- /dev/null
+++ b/src/typings/macos-keychain.d.ts
@@ -0,0 +1,15 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+// Type declarations for @vscode/macos-keychain.
+// The package is an optional dependency (macOS-only native addon), so types
+// are duplicated here to ensure TypeScript compilation succeeds on all platforms.
+
+declare module '@vscode/macos-keychain' {
+ export function keychainSet(service: string, account: string, value: string): void;
+ export function keychainGet(service: string, account: string): string | undefined;
+ export function keychainDelete(service: string, account: string): boolean;
+ export function keychainList(service: string): string[];
+}
diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts
index bc7c8f68eeb92..2e820ec314bea 100644
--- a/src/vs/base/common/product.ts
+++ b/src/vs/base/common/product.ts
@@ -224,6 +224,7 @@ export interface IProductConfiguration {
readonly darwinUniversalAssetId?: string;
readonly darwinBundleIdentifier?: string;
readonly darwinSiblingBundleIdentifier?: string;
+ readonly darwinSharedKeychainServiceName?: string;
readonly profileTemplatesUrl?: string;
readonly commonlyUsedSettings?: string[];
diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts
index bf4043983fdb1..a2946dff2b5bb 100644
--- a/src/vs/code/electron-main/app.ts
+++ b/src/vs/code/electron-main/app.ts
@@ -37,6 +37,8 @@ import { DiagnosticsMainService, IDiagnosticsMainService } from '../../platform/
import { DialogMainService, IDialogMainService } from '../../platform/dialogs/electron-main/dialogMainService.js';
import { IEncryptionMainService } from '../../platform/encryption/common/encryptionService.js';
import { EncryptionMainService } from '../../platform/encryption/electron-main/encryptionMainService.js';
+import { ISharedKeychainMainService } from '../../platform/secrets/common/sharedKeychainService.js';
+import { SharedKeychainMainService } from '../../platform/secrets/electron-main/sharedKeychainMainService.js';
import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js';
import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js';
import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js';
@@ -1092,6 +1094,9 @@ export class CodeApplication extends Disposable {
// Encryption
services.set(IEncryptionMainService, new SyncDescriptor(EncryptionMainService));
+ // Shared Keychain
+ services.set(ISharedKeychainMainService, new SyncDescriptor(SharedKeychainMainService));
+
// Cross-app IPC
services.set(ICrossAppIPCService, new SyncDescriptor(CrossAppIPCService));
@@ -1270,12 +1275,12 @@ export class CodeApplication extends Disposable {
this._register(new MacOSCrossAppSecretSharing(
accessor.get(IStorageMainService),
accessor.get(IEncryptionMainService),
+ accessor.get(ISharedKeychainMainService),
accessor.get(IStateService),
this.logService,
this.environmentMainService,
accessor.get(ILaunchMainService),
this.lifecycleMainService,
- crossAppIPCService,
));
}
@@ -1292,6 +1297,10 @@ export class CodeApplication extends Disposable {
const encryptionChannel = ProxyChannel.fromService(accessor.get(IEncryptionMainService), disposables);
mainProcessElectronServer.registerChannel('encryption', encryptionChannel);
+ // Shared Keychain
+ const sharedKeychainChannel = ProxyChannel.fromService(accessor.get(ISharedKeychainMainService), disposables);
+ mainProcessElectronServer.registerChannel('sharedKeychain', sharedKeychainChannel);
+
// Browser View
const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables);
mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel);
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
index 23cc1ce9131e4..4555cb5bf7ac2 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
@@ -32,6 +32,13 @@ import { buildPendingEditContentUri } from './pendingEditContentStore.js';
const COPILOT_HOME_DIRECTORY = '.copilot';
const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state');
+type UserInputHandler = NonNullable;
+type UserInputRequest = Parameters[0];
+type UserInputResponse = Awaited>;
+type SessionHooks = NonNullable;
+type PreToolUseHookInput = Parameters>[0];
+type PostToolUseHookInput = Parameters>[0];
+
function getCopilotCLISessionStateDir(userHome: string): string {
const xdgHome = process.env['XDG_STATE_HOME'];
return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(userHome, SESSION_STATE_DIRECTORY);
@@ -58,28 +65,15 @@ export interface IActiveClientSnapshot {
*/
export type SessionWrapperFactory = (callbacks: {
readonly onPermissionRequest: (request: ITypedPermissionRequest) => Promise;
- readonly onUserInputRequest: (request: IUserInputRequest, invocation: { sessionId: string }) => Promise;
+ readonly onUserInputRequest: (request: UserInputRequest, invocation: { sessionId: string }) => Promise;
readonly hooks: {
- readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise;
- readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise;
+ readonly onPreToolUse: (input: PreToolUseHookInput) => Promise;
+ readonly onPostToolUse: (input: PostToolUseHookInput) => Promise;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly clientTools: Tool[];
}) => Promise;
-/** Matches the SDK's `UserInputRequest` which is not re-exported from the package entry point. */
-interface IUserInputRequest {
- question: string;
- choices?: string[];
- allowFreeform?: boolean;
-}
-
-/** Matches the SDK's `UserInputResponse` which is not re-exported from the package entry point. */
-interface IUserInputResponse {
- answer: string;
- wasFreeform: boolean;
-}
-
/**
* Options for constructing a {@link CopilotAgentSession}.
*/
@@ -245,15 +239,14 @@ export class CopilotAgentSession extends Disposable {
}
const textContent = result.content
- ?.filter(c => c.type === 'text')
- .map(c => (c as { text: string }).text)
+ ?.filter(c => c.type === ToolResultContentType.Text)
+ .map(c => c.text)
.join('\n') ?? '';
const binaryResults = result.content
- ?.filter(c => c.type === 'embeddedResource')
+ ?.filter(c => c.type === ToolResultContentType.EmbeddedResource)
.map(c => {
- const embedded = c as { data: string; contentType: string };
- return { data: embedded.data, mimeType: embedded.contentType, type: embedded.contentType };
+ return { data: c.data, mimeType: c.contentType, type: c.contentType };
});
if (result.success) {
@@ -537,9 +530,9 @@ export class CopilotAgentSession extends Disposable {
* respond via {@link respondToUserInputRequest}.
*/
async handleUserInputRequest(
- request: IUserInputRequest,
+ request: UserInputRequest,
_invocation: { sessionId: string },
- ): Promise {
+ ): Promise {
const questionPreview = request.question.substring(0, 100);
try {
const requestId = generateUuid();
@@ -615,7 +608,7 @@ export class CopilotAgentSession extends Disposable {
return false;
}
- private async _handlePreToolUse(input: { toolName: string; toolArgs: unknown }): Promise {
+ private async _handlePreToolUse(input: PreToolUseHookInput): Promise {
try {
if (isEditTool(input.toolName)) {
const filePath = getEditFilePath(input.toolArgs);
@@ -629,7 +622,7 @@ export class CopilotAgentSession extends Disposable {
}
}
- private async _handlePostToolUse(input: { toolName: string; toolArgs: unknown }): Promise {
+ private async _handlePostToolUse(input: PostToolUseHookInput): Promise {
try {
if (isEditTool(input.toolName)) {
const filePath = getEditFilePath(input.toolArgs);
diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts
index 4be688fba030d..61e93e5a6c1e3 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts
@@ -12,6 +12,12 @@ import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IP
import { dirname } from '../../../../base/common/path.js';
type SessionHooks = NonNullable;
+type PreToolUseHookInput = Parameters>[0];
+type PostToolUseHookInput = Parameters>[0];
+type UserPromptSubmittedHookInput = Parameters>[0];
+type SessionStartHookInput = Parameters>[0];
+type SessionEndHookInput = Parameters>[0];
+type ErrorOccurredHookInput = Parameters>[0];
// ---------------------------------------------------------------------------
// MCP servers
@@ -226,8 +232,8 @@ const HOOK_TYPE_TO_SDK_KEY: Record = {
export function toSdkHooks(
hookGroups: readonly IParsedHookGroup[],
editTrackingHooks?: {
- readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise;
- readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise;
+ readonly onPreToolUse: (input: PreToolUseHookInput) => Promise;
+ readonly onPostToolUse: (input: PostToolUseHookInput) => Promise;
},
): SessionHooks {
// Group all commands by SDK handler key
@@ -247,7 +253,7 @@ export function toSdkHooks(
// Pre-tool-use handler
const preToolCommands = commandsByKey.get('onPreToolUse');
if (preToolCommands?.length || editTrackingHooks) {
- hooks.onPreToolUse = async (input: { toolName: string; toolArgs: unknown }) => {
+ hooks.onPreToolUse = async (input: PreToolUseHookInput) => {
await editTrackingHooks?.onPreToolUse(input);
return runHookCommands(preToolCommands, input);
};
@@ -256,7 +262,7 @@ export function toSdkHooks(
// Post-tool-use handler
const postToolCommands = commandsByKey.get('onPostToolUse');
if (postToolCommands?.length || editTrackingHooks) {
- hooks.onPostToolUse = async (input: { toolName: string; toolArgs: unknown }) => {
+ hooks.onPostToolUse = async (input: PostToolUseHookInput) => {
await editTrackingHooks?.onPostToolUse(input);
return runHookCommands(postToolCommands, input);
};
@@ -265,7 +271,7 @@ export function toSdkHooks(
// User-prompt-submitted handler
const promptCommands = commandsByKey.get('onUserPromptSubmitted');
if (promptCommands?.length) {
- hooks.onUserPromptSubmitted = async (input: { prompt: string }) => {
+ hooks.onUserPromptSubmitted = async (input: UserPromptSubmittedHookInput) => {
const stdin = JSON.stringify(input);
for (const cmd of promptCommands) {
try {
@@ -280,7 +286,7 @@ export function toSdkHooks(
// Session-start handler
const startCommands = commandsByKey.get('onSessionStart');
if (startCommands?.length) {
- hooks.onSessionStart = async (input: { source: string }) => {
+ hooks.onSessionStart = async (input: SessionStartHookInput) => {
const stdin = JSON.stringify(input);
for (const cmd of startCommands) {
try {
@@ -295,7 +301,7 @@ export function toSdkHooks(
// Session-end handler
const endCommands = commandsByKey.get('onSessionEnd');
if (endCommands?.length) {
- hooks.onSessionEnd = async (input: { reason: string }) => {
+ hooks.onSessionEnd = async (input: SessionEndHookInput) => {
const stdin = JSON.stringify(input);
for (const cmd of endCommands) {
try {
@@ -310,7 +316,7 @@ export function toSdkHooks(
// Error-occurred handler
const errorCommands = commandsByKey.get('onErrorOccurred');
if (errorCommands?.length) {
- hooks.onErrorOccurred = async (input: { error: string }) => {
+ hooks.onErrorOccurred = async (input: ErrorOccurredHookInput) => {
const stdin = JSON.stringify(input);
for (const cmd of errorCommands) {
try {
diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
index a59fafd93268f..93b8331262de3 100644
--- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
+++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk';
+import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk';
import assert from 'assert';
import { DeferredPromise } from '../../../../base/common/async.js';
import { Emitter } from '../../../../base/common/event.js';
@@ -83,7 +83,7 @@ class CapturingLogService extends NullLogService {
* {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s
* handler implementation actually returns.
*/
-function invokeClientToolHandler(tool: { name: string; handler: (args: any, invocation: any) => unknown }, toolCallId: string): Promise {
+function invokeClientToolHandler(tool: Pick, toolCallId: string): Promise {
return Promise.resolve(tool.handler({}, {
sessionId: 'test-session-1',
toolCallId,
@@ -882,7 +882,12 @@ suite('CopilotAgentSession', () => {
};
await assert.rejects(
- capturedCallbacks.current!.hooks.onPreToolUse({ toolName: 'edit', toolArgs: { path: '/tmp/file.ts' } }),
+ capturedCallbacks.current!.hooks.onPreToolUse({
+ timestamp: 0,
+ cwd: '/tmp',
+ toolName: 'edit',
+ toolArgs: { path: '/tmp/file.ts' },
+ }),
/pre tool boom/,
);
@@ -903,7 +908,13 @@ suite('CopilotAgentSession', () => {
};
await assert.rejects(
- capturedCallbacks.current!.hooks.onPostToolUse({ toolName: 'edit', toolArgs: { path: '/tmp/file.ts' } }),
+ capturedCallbacks.current!.hooks.onPostToolUse({
+ timestamp: 0,
+ cwd: '/tmp',
+ toolName: 'edit',
+ toolArgs: { path: '/tmp/file.ts' },
+ toolResult: { textResultForLlm: '', resultType: 'success' },
+ }),
/post tool boom/,
);
diff --git a/src/vs/platform/secrets/common/secrets.ts b/src/vs/platform/secrets/common/secrets.ts
index 1840cdd6b284d..220442805fe8b 100644
--- a/src/vs/platform/secrets/common/secrets.ts
+++ b/src/vs/platform/secrets/common/secrets.ts
@@ -76,8 +76,6 @@ export async function writeEncryptedSecret(
/**
* Secret keys that should be shared between the VS Code app and the agents app.
- * When the agents app starts and doesn't have these secrets, it requests them
- * from VS Code via crossAppIPC.
*/
export const CROSS_APP_SHARED_SECRET_KEYS: readonly string[] = [
'{"extensionId":"vscode.github-authentication","key":"github.auth"}',
@@ -137,65 +135,89 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora
}
get(key: string): Promise {
- return this._sequencer.queue(key, async () => {
- const storageService = await this.resolvedStorageService;
-
- try {
- return await readEncryptedSecret(
- key,
- (fullKey) => this.getValueFromStorage(key, fullKey, storageService),
- // If the storage service is in-memory, we don't need to decrypt
- this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.decrypt(v),
- this._logService,
- );
- } catch (e) {
- this._logService.error(e);
- this.delete(key);
- return undefined;
- }
- });
+ return this._sequencer.queue(key, () => this._doGet(key));
+ }
+
+ /**
+ * Read from the safeStorage+SQLite pipeline without going through the sequencer.
+ * Must only be called from within a sequencer-queued task for the same key.
+ */
+ protected async _doGet(key: string): Promise {
+ const storageService = await this.resolvedStorageService;
+
+ try {
+ return await readEncryptedSecret(
+ key,
+ (fullKey) => this.getValueFromStorage(key, fullKey, storageService),
+ // If the storage service is in-memory, we don't need to decrypt
+ this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.decrypt(v),
+ this._logService,
+ );
+ } catch (e) {
+ this._logService.error(e);
+ this.delete(key);
+ return undefined;
+ }
}
set(key: string, value: string): Promise {
- return this._sequencer.queue(key, async () => {
- const storageService = await this.resolvedStorageService;
-
- try {
- await writeEncryptedSecret(
- key,
- value,
- (fullKey, encrypted) => this.setValueInStorage(key, fullKey, encrypted, storageService),
- // If the storage service is in-memory, we don't need to encrypt
- this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.encrypt(v),
- this._logService,
- );
- } catch (e) {
- this._logService.error(e);
- throw e;
- }
- });
+ return this._sequencer.queue(key, () => this._doSet(key, value));
+ }
+
+ /**
+ * Write to the safeStorage+SQLite pipeline without going through the sequencer.
+ * Must only be called from within a sequencer-queued task for the same key.
+ */
+ protected async _doSet(key: string, value: string): Promise {
+ const storageService = await this.resolvedStorageService;
+
+ try {
+ await writeEncryptedSecret(
+ key,
+ value,
+ (fullKey, encrypted) => this.setValueInStorage(key, fullKey, encrypted, storageService),
+ // If the storage service is in-memory, we don't need to encrypt
+ this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.encrypt(v),
+ this._logService,
+ );
+ } catch (e) {
+ this._logService.error(e);
+ throw e;
+ }
}
delete(key: string): Promise {
- return this._sequencer.queue(key, async () => {
- const storageService = await this.resolvedStorageService;
-
- const fullKey = secretStorageKey(key);
- this._logService.trace('[secrets] deleting secret for key:', fullKey);
- const scope = this.useSharedStorage(key) ? StorageScope.APPLICATION_SHARED : StorageScope.APPLICATION;
- storageService.remove(fullKey, scope);
- this._logService.trace('[secrets] deleted secret for key:', fullKey);
- });
+ return this._sequencer.queue(key, () => this._doDelete(key));
+ }
+
+ /**
+ * Delete from the safeStorage+SQLite pipeline without going through the sequencer.
+ * Must only be called from within a sequencer-queued task for the same key.
+ */
+ protected async _doDelete(key: string): Promise {
+ const storageService = await this.resolvedStorageService;
+
+ const fullKey = secretStorageKey(key);
+ this._logService.trace('[secrets] deleting secret for key:', fullKey);
+ const scope = this.useSharedStorage(key) ? StorageScope.APPLICATION_SHARED : StorageScope.APPLICATION;
+ storageService.remove(fullKey, scope);
+ this._logService.trace('[secrets] deleted secret for key:', fullKey);
}
keys(): Promise {
- return this._sequencer.queue('__keys__', async () => {
- const storageService = await this.resolvedStorageService;
- this._logService.trace('[secrets] fetching keys of all secrets');
- const allKeys = storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE);
- this._logService.trace('[secrets] fetched keys of all secrets');
- return allKeys.filter(key => key.startsWith(SECRET_STORAGE_PREFIX)).map(key => key.slice(SECRET_STORAGE_PREFIX.length));
- });
+ return this._sequencer.queue('__keys__', () => this._doGetKeys());
+ }
+
+ /**
+ * List all secret keys from the safeStorage+SQLite pipeline without going through the sequencer.
+ * Must only be called from within a sequencer-queued task.
+ */
+ protected async _doGetKeys(): Promise {
+ const storageService = await this.resolvedStorageService;
+ this._logService.trace('[secrets] fetching keys of all secrets');
+ const allKeys = storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE);
+ this._logService.trace('[secrets] fetched keys of all secrets');
+ return allKeys.filter(key => key.startsWith(SECRET_STORAGE_PREFIX)).map(key => key.slice(SECRET_STORAGE_PREFIX.length));
}
private getValueFromStorage(key: string, fullKey: string, storageService: IStorageService): string | undefined {
diff --git a/src/vs/platform/secrets/common/sharedKeychainService.ts b/src/vs/platform/secrets/common/sharedKeychainService.ts
new file mode 100644
index 0000000000000..f99485e790011
--- /dev/null
+++ b/src/vs/platform/secrets/common/sharedKeychainService.ts
@@ -0,0 +1,25 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { createDecorator } from '../../instantiation/common/instantiation.js';
+
+/**
+ * Provides shared keychain access between Code and the embedded Agents app
+ * via a macOS keychain access group. On non-macOS platforms the implementation
+ * is a no-op (returns undefined/empty for all operations).
+ */
+export const ISharedKeychainService = createDecorator('sharedKeychainService');
+
+export interface ISharedKeychainService {
+ readonly _serviceBrand: undefined;
+ get(key: string): Promise;
+ set(key: string, value: string): Promise;
+ delete(key: string): Promise;
+ keys(): Promise;
+}
+
+export const ISharedKeychainMainService = createDecorator('sharedKeychainMainService');
+
+export interface ISharedKeychainMainService extends ISharedKeychainService { }
diff --git a/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts b/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts
index 6dfea9bc15a96..c95e639f0729c 100644
--- a/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts
+++ b/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts
@@ -5,75 +5,49 @@
import { execFile } from 'child_process';
import { dirname } from '../../../base/common/path.js';
-import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
+import { Disposable } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
import { IEncryptionMainService } from '../../encryption/common/encryptionService.js';
import { IStorageMainService } from '../../storage/electron-main/storageMainService.js';
-import { CROSS_APP_SHARED_SECRET_KEYS, secretStorageKey, readEncryptedSecret, writeEncryptedSecret } from '../common/secrets.js';
+import { CROSS_APP_SHARED_SECRET_KEYS, readEncryptedSecret } from '../common/secrets.js';
import { IStateService } from '../../state/node/state.js';
import { INodeProcess, isMacintosh } from '../../../base/common/platform.js';
import { IStorageMain } from '../../storage/electron-main/storageMain.js';
import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js';
import { ILaunchMainService } from '../../launch/electron-main/launchMainService.js';
import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js';
-import { ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js';
+import { ISharedKeychainMainService } from '../common/sharedKeychainService.js';
-const MIGRATION_STATE_KEY = 'crossAppSecretSharing.migrationDone';
-
-/**
- * Message types exchanged between apps over crossAppIPC for secret sharing.
- */
-const enum CrossAppSecretMessageType {
- /** Agents → Host: Request secrets */
- SecretRequest = 'secrets/request',
- /** Host → Agents: Response with secrets */
- SecretResponse = 'secrets/response',
- /** Agents → Host: Confirms secrets were stored, both sides mark migration done */
- SecretAck = 'secrets/ack',
-}
-
-interface CrossAppSecretMessage {
- type: CrossAppSecretMessageType;
- data?: Record;
-}
+const MIGRATION_STATE_KEY = 'sharedKeychain.migrationDone';
+const HOST_SPAWN_STATE_KEY = 'sharedKeychain.hostSpawnDone';
/**
* Coordinates one-time secret migration between the VS Code app and the
- * agents app using Electron's crossAppIPC (macOS only).
+ * agents app via the macOS shared keychain (macOS only).
*
- * **Demand-driven**: Only the agents app initiates migration. If it
- * detects that migration hasn't been done yet, it:
- * 1. Waits for the crossAppIPC connection (managed by ICrossAppIPCService).
- * 2. Spawns Code.app with `--share-secrets-with-agents-app`, which
- * either starts Code.app fresh or (if already running) forwards
- * the arg to the existing instance via the node IPC socket.
- * 3. Code.app creates its own crossAppIPC connection when it sees
- * the arg, and the two connect.
- * 4. Agents app sends `SecretRequest` → Code.app responds with
- * `SecretResponse` → Agents app sends `SecretAck`.
- * 5. Both sides mark migration as done. Code.app quits if it was
- * launched solely for this purpose.
+ * Each app migrates its own secrets from safeStorage+SQLite into the
+ * shared keychain on startup. The agents app also spawns Code.app
+ * (once) with `--share-secrets-with-agents-app` to trigger Code's
+ * migration if the shared keychain doesn't yet contain all expected
+ * keys.
*
- * Security: crossAppIPC uses code-signature verification (Mach ports
- * on macOS) — the kernel authenticates both endpoints. No secrets are
- * ever in process args, files, or network.
+ * After migration, both apps read from and write to the shared keychain
+ * for cross-app secret keys (via {@link NativeSecretStorageService}).
*/
export class MacOSCrossAppSecretSharing extends Disposable {
private readonly isEmbeddedApp: boolean;
private readonly applicationStorage: IStorageMain;
- private _onHostMigrationComplete: (() => void) | undefined;
- private readonly hostHandshakeListeners = this._register(new DisposableStore());
constructor(
storageMainService: IStorageMainService,
private readonly encryptionMainService: IEncryptionMainService,
+ private readonly sharedKeychainMainService: ISharedKeychainMainService,
private readonly stateService: IStateService,
private readonly logService: ILogService,
environmentMainService: IEnvironmentMainService,
launchMainService: ILaunchMainService,
lifecycleMainService: ILifecycleMainService,
- private readonly crossAppIPCService: ICrossAppIPCService,
) {
super();
this.isEmbeddedApp = !!(process as INodeProcess).isEmbeddedApp;
@@ -87,143 +61,96 @@ export class MacOSCrossAppSecretSharing extends Disposable {
lifecycleMainService: ILifecycleMainService,
): void {
if (this.isEmbeddedApp) {
- // Agents app: initiate migration if needed
+ // Agents app: migrate own secrets + spawn Code.app if needed
this.initializeAsAgentsApp();
} else if (environmentMainService.args['share-secrets-with-agents-app']) {
- // Code.app launched fresh with --share-secrets-with-agents-app:
- // respond to the agents app's request, then quit if no other reason to stay
+ // Code.app launched with --share-secrets-with-agents-app:
+ // migrate secrets to shared keychain, then quit if no other reason to stay
const hasOtherArgs = environmentMainService.args._.length > 0 || environmentMainService.args['folder-uri'] || environmentMainService.args['file-uri'];
- this.initializeAsHostApp(hasOtherArgs ? undefined : () => {
- this.logService.info('[CrossAppSecretSharing] Host app was launched for migration only, quitting');
- lifecycleMainService.quit();
+ this.migrateSecrets().then(() => {
+ if (!hasOtherArgs) {
+ this.logService.info('[CrossAppSecretSharing] Host app was launched for migration only, quitting');
+ lifecycleMainService.quit();
+ }
});
} else {
- // Code.app already running: listen for --share-secrets-with-agents-app
- // forwarded from a second instance via the launch service
+ // Code.app normal startup: migrate own secrets
+ this.migrateSecrets();
+ // Also respond to spawn requests from the agents app
this._register(launchMainService.onDidRequestShareSecrets(() => {
- this.initializeAsHostApp();
+ this.migrateSecrets();
}));
}
}
private async initializeAsAgentsApp(): Promise {
- if (!isMacintosh || !this.isEmbeddedApp) {
+ if (!isMacintosh) {
return;
}
- if (this.isMigrationDone()) {
- this.logService.trace('[CrossAppSecretSharing] Migration already done, skipping');
- return;
- }
+ // Migrate own secrets (if any) to shared keychain
+ await this.migrateSecrets();
- // Wait for storage to be ready before we start — handleSecretResponse
- // will write secrets into applicationStorage.
- await this.applicationStorage.whenInit;
-
- if (!this.crossAppIPCService.initialized) {
- this.logService.info('[CrossAppSecretSharing] crossAppIPC not initialized, skipping migration');
+ // If we've already spawned Code.app before, don't do it again
+ if (this.stateService.getItem(HOST_SPAWN_STATE_KEY, false)) {
return;
}
- this.logService.info('[CrossAppSecretSharing] Migration needed, starting...');
-
- // Listen for connection — when connected, request secrets
- this._register(this.crossAppIPCService.onDidConnect(isServer => {
- this.logService.info(`[CrossAppSecretSharing] Connected (isServer=${isServer}), requesting secrets from host app`);
- this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretRequest });
- }));
-
- // Listen for messages
- this._register(this.crossAppIPCService.onDidReceiveMessage(msg => {
- const secretMsg = msg as CrossAppSecretMessage;
- if (secretMsg?.type === CrossAppSecretMessageType.SecretResponse) {
- this.handleSecretResponse(secretMsg.data ?? {});
+ // Check if the shared keychain has all expected keys
+ let needsHostMigration = false;
+ for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
+ if (await this.sharedKeychainMainService.get(key) === undefined) {
+ needsHostMigration = true;
+ break;
}
- }));
-
- // If already connected (e.g. service was initialized before storage was ready),
- // send the request immediately.
- if (this.crossAppIPCService.connected) {
- this.logService.info(`[CrossAppSecretSharing] Already connected (isServer=${this.crossAppIPCService.isServer}), requesting secrets from host app`);
- this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretRequest });
}
- // Spawn Code.app with --share-secrets-with-agents-app
- this.spawnHostApp();
+ if (needsHostMigration) {
+ this.logService.info('[CrossAppSecretSharing] Shared keychain incomplete, spawning host app');
+ this.spawnHostApp();
+ }
- // Timeout: if migration doesn't complete within 30s, give up
- setTimeout(() => {
- if (!this.isMigrationDone()) {
- this.logService.warn('[CrossAppSecretSharing] Migration timed out');
- }
- }, 30_000);
+ // Mark that we've attempted the host spawn (don't retry on next startup)
+ this.stateService.setItem(HOST_SPAWN_STATE_KEY, true);
}
- private async initializeAsHostApp(onComplete?: () => void): Promise {
- if (!isMacintosh || this.isEmbeddedApp) {
- onComplete?.();
+ /**
+ * Migrates this app's secrets from safeStorage+SQLite to the shared keychain.
+ * Idempotent — skips if already done.
+ */
+ private async migrateSecrets(): Promise {
+ if (!isMacintosh) {
return;
}
- if (this.isMigrationDone()) {
+ if (this.stateService.getItem(MIGRATION_STATE_KEY, false)) {
this.logService.trace('[CrossAppSecretSharing] Migration already done, skipping');
- onComplete?.();
return;
}
- // Wait for application storage to be fully initialized before
- // checking for secrets — storage may still be in-memory at this
- // point during early startup.
await this.applicationStorage.whenInit;
- if (!this.hasAnySharedSecrets()) {
- this.logService.trace('[CrossAppSecretSharing] No shared secrets to share, skipping');
- onComplete?.();
- return;
- }
-
- if (!this.crossAppIPCService.initialized) {
- this.logService.info('[CrossAppSecretSharing] crossAppIPC not initialized');
- onComplete?.();
- return;
- }
-
- this._onHostMigrationComplete = onComplete;
-
- this.logService.info('[CrossAppSecretSharing] Host app responding to secret sharing request');
-
- // Dispose previous listeners if initializeAsHostApp is called again
- // (e.g. via repeated onDidRequestShareSecrets events).
- this.hostHandshakeListeners.clear();
-
- // Listen for messages from the agents app
- this.hostHandshakeListeners.add(this.crossAppIPCService.onDidReceiveMessage(msg => {
- const secretMsg = msg as CrossAppSecretMessage;
- if (secretMsg?.type === CrossAppSecretMessageType.SecretRequest) {
- this.handleSecretRequest();
- } else if (secretMsg?.type === CrossAppSecretMessageType.SecretAck) {
- this.handleSecretAck();
- }
- }));
-
- // If disconnected before ack, still allow the host to quit
- this.hostHandshakeListeners.add(this.crossAppIPCService.onDidDisconnect(() => {
- this._onHostMigrationComplete?.();
- this._onHostMigrationComplete = undefined;
- }));
- }
-
- private isMigrationDone(): boolean {
- return this.stateService.getItem(MIGRATION_STATE_KEY, false);
- }
+ this.logService.info('[CrossAppSecretSharing] Starting shared keychain migration');
- private hasAnySharedSecrets(): boolean {
for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
- if (this.applicationStorage.get(secretStorageKey(key)) !== undefined) {
- return true;
+ try {
+ const decrypted = await readEncryptedSecret(
+ key,
+ (fullKey) => this.applicationStorage.get(fullKey),
+ (value) => this.encryptionMainService.decrypt(value),
+ this.logService,
+ );
+ if (decrypted !== undefined) {
+ await this.sharedKeychainMainService.set(key, decrypted);
+ this.logService.trace('[CrossAppSecretSharing] Migrated key to shared keychain:', key);
+ }
+ } catch (err) {
+ this.logService.error('[CrossAppSecretSharing] Failed to migrate key:', key, err);
}
}
- return false;
+
+ this.stateService.setItem(MIGRATION_STATE_KEY, true);
+ this.logService.info('[CrossAppSecretSharing] Migration complete');
}
private spawnHostApp(): void {
@@ -247,69 +174,4 @@ export class MacOSCrossAppSecretSharing extends Disposable {
});
child.unref();
}
-
- private async handleSecretRequest(): Promise {
- this.logService.info('[CrossAppSecretSharing] Host app handling secret request');
-
- const secrets: Record = {};
-
- for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
- try {
- const decrypted = await readEncryptedSecret(
- key,
- (fullKey) => this.applicationStorage.get(fullKey),
- (value) => this.encryptionMainService.decrypt(value),
- this.logService,
- );
- if (decrypted !== undefined) {
- secrets[key] = decrypted;
- }
- } catch (err) {
- this.logService.error('[CrossAppSecretSharing] Failed to read secret for key:', key, err);
- }
- }
-
- this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretResponse, data: secrets });
- this.logService.info('[CrossAppSecretSharing] Sent secrets response with', Object.keys(secrets).length, 'keys');
- }
-
- private async handleSecretResponse(secrets: Record): Promise {
- this.logService.info('[CrossAppSecretSharing] Agents app received', Object.keys(secrets).length, 'secrets');
-
- for (const [key, value] of Object.entries(secrets)) {
- if (!CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
- this.logService.warn('[CrossAppSecretSharing] Ignoring unexpected key:', key);
- continue;
- }
-
- try {
- await writeEncryptedSecret(
- key,
- value,
- (fullKey, encrypted) => this.applicationStorage.set(fullKey, encrypted),
- (v) => this.encryptionMainService.encrypt(v),
- this.logService,
- );
- } catch (err) {
- this.logService.error('[CrossAppSecretSharing] Failed to store secret for key:', key, err);
- }
- }
-
- this.stateService.setItem(MIGRATION_STATE_KEY, true);
- this.logService.info('[CrossAppSecretSharing] Migration complete');
-
- // Tell the host app migration is done so it can also record it.
- // Don't close here — let the host close first after receiving the ack.
- this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretAck });
- }
-
- private handleSecretAck(): void {
- this.stateService.setItem(MIGRATION_STATE_KEY, true);
- this.logService.info('[CrossAppSecretSharing] Host app received ack, migration complete on both sides');
-
- const onComplete = this._onHostMigrationComplete;
- this._onHostMigrationComplete = undefined;
-
- onComplete?.();
- }
}
diff --git a/src/vs/platform/secrets/electron-main/sharedKeychainMainService.ts b/src/vs/platform/secrets/electron-main/sharedKeychainMainService.ts
new file mode 100644
index 0000000000000..3ebeb172d97e2
--- /dev/null
+++ b/src/vs/platform/secrets/electron-main/sharedKeychainMainService.ts
@@ -0,0 +1,92 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { isMacintosh } from '../../../base/common/platform.js';
+import { ISharedKeychainMainService } from '../common/sharedKeychainService.js';
+import { ILogService } from '../../log/common/log.js';
+import { IProductService } from '../../product/common/productService.js';
+
+type KeychainModule = typeof import('@vscode/macos-keychain');
+
+export class SharedKeychainMainService implements ISharedKeychainMainService {
+ declare readonly _serviceBrand: undefined;
+
+ private _modulePromise: Promise | undefined;
+ private readonly serviceName: string;
+ private readonly enabled: boolean;
+
+ constructor(
+ @IProductService productService: IProductService,
+ @ILogService private readonly logService: ILogService,
+ ) {
+ this.enabled = isMacintosh && !!productService.darwinSharedKeychainServiceName;
+ this.serviceName = productService.darwinSharedKeychainServiceName ?? '';
+ }
+
+ private getModule(): Promise {
+ if (!this._modulePromise) {
+ this._modulePromise = import('@vscode/macos-keychain');
+ }
+ return this._modulePromise;
+ }
+
+ async get(key: string): Promise {
+ if (!this.enabled) {
+ return undefined;
+ }
+ try {
+ const mod = await this.getModule();
+ const value = mod.keychainGet(this.serviceName, key);
+ this.logService.trace('[SharedKeychainMainService] get:', key, value !== undefined ? '(found)' : '(not found)');
+ return value;
+ } catch (err) {
+ this.logService.error('[SharedKeychainMainService] get failed:', key, err);
+ return undefined;
+ }
+ }
+
+ async set(key: string, value: string): Promise {
+ if (!this.enabled) {
+ return;
+ }
+ try {
+ const mod = await this.getModule();
+ mod.keychainSet(this.serviceName, key, value);
+ this.logService.trace('[SharedKeychainMainService] set:', key);
+ } catch (err) {
+ this.logService.error('[SharedKeychainMainService] set failed:', key, err);
+ }
+ }
+
+ async delete(key: string): Promise {
+ if (!this.enabled) {
+ return false;
+ }
+ try {
+ const mod = await this.getModule();
+ const deleted = mod.keychainDelete(this.serviceName, key);
+ this.logService.trace('[SharedKeychainMainService] delete:', key, deleted ? '(deleted)' : '(not found)');
+ return deleted;
+ } catch (err) {
+ this.logService.error('[SharedKeychainMainService] delete failed:', key, err);
+ return false;
+ }
+ }
+
+ async keys(): Promise {
+ if (!this.enabled) {
+ return [];
+ }
+ try {
+ const mod = await this.getModule();
+ const result = mod.keychainList(this.serviceName);
+ this.logService.trace('[SharedKeychainMainService] keys: found', result.length, 'entries');
+ return result;
+ } catch (err) {
+ this.logService.error('[SharedKeychainMainService] keys failed:', err);
+ return [];
+ }
+ }
+}
diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts
index 99ee2d015f208..a11a3fe97e37d 100644
--- a/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts
+++ b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts
@@ -15,6 +15,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOp
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js';
+import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
const PERMISSION_MODE_OPTION_ID = 'permissionMode';
@@ -56,6 +57,7 @@ export class ClaudePermissionModePicker extends Disposable {
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
+ @IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
) {
super();
}
@@ -140,7 +142,16 @@ export class ClaudePermissionModePicker extends Disposable {
}
const provider = this.sessionsProvidersService.getProvider(session.providerId);
if (provider instanceof CopilotChatSessionsProvider) {
- provider.getSession(session.sessionId)?.setOption?.(PERMISSION_MODE_OPTION_ID, { id: mode.id, name: mode.label });
+ const chatSession = provider.getSession(session.sessionId);
+ if (!chatSession) {
+ return;
+ }
+ const option = { id: mode.id, name: mode.label };
+ if (chatSession.setOption) {
+ chatSession.setOption(PERMISSION_MODE_OPTION_ID, option);
+ } else {
+ this.chatSessionsService.setSessionOption(chatSession.resource, PERMISSION_MODE_OPTION_ID, option);
+ }
}
}
diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts
index 135d30996ecbc..1d499583f6d2d 100644
--- a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts
+++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts
@@ -23,6 +23,9 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { URI } from '../../../../base/common/uri.js';
import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';
+import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
+
+const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
/**
* Strategy for the per-provider parts of {@link PermissionPicker}: how to read
@@ -348,6 +351,7 @@ export class CopilotPermissionPickerDelegate extends Disposable implements IPerm
constructor(
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
+ @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,
) {
super();
}
@@ -359,7 +363,16 @@ export class CopilotPermissionPickerDelegate extends Disposable implements IPerm
}
const provider = this._sessionsProvidersService.getProvider(session.providerId);
if (provider instanceof CopilotChatSessionsProvider) {
- provider.getSession(session.sessionId)?.setPermissionLevel(level);
+ const chatSession = provider.getSession(session.sessionId);
+ if (!chatSession) {
+ return;
+ }
+ if (chatSession.setOption) {
+ chatSession.setPermissionLevel(level);
+ chatSession.setOption(PERMISSION_LEVEL_OPTION_ID, level);
+ } else {
+ this._chatSessionsService.setSessionOption(chatSession.resource, PERMISSION_LEVEL_OPTION_ID, level);
+ }
}
}
}
diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts
index 24bb3646edbdb..49e3d3f763af4 100644
--- a/src/vs/sessions/sessions.desktop.main.ts
+++ b/src/vs/sessions/sessions.desktop.main.ts
@@ -58,6 +58,7 @@ import '../workbench/services/mcp/electron-browser/mcpWorkbenchManagementService
import '../workbench/services/encryption/electron-browser/encryptionService.js';
import '../workbench/services/imageResize/electron-browser/imageResizeService.js';
import '../workbench/services/secrets/electron-browser/secretStorageService.js';
+import '../workbench/services/secrets/electron-browser/sharedKeychainService.js';
import '../workbench/services/localization/electron-browser/languagePackService.js';
import '../workbench/services/telemetry/electron-browser/telemetryService.js';
import '../workbench/services/extensions/electron-browser/extensionHostStarter.js';
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts
index 6f1c9568a0f90..03568600148b8 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts
@@ -152,6 +152,14 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
const agentId = sessionType;
const vendor = sessionType;
+ // In the Agents app, the agent-host displayName is unambiguous because
+ // only agent-host sessions exist there. In VS Code, the same picker
+ // also lists the extension-host harness with the same displayName
+ // (e.g. "Copilot CLI"), so suffix with "- Agent Host" to disambiguate.
+ const displayName = this._isSessionsWindow
+ ? agent.displayName
+ : localize('agentHost.displayName', "{0} - Agent Host", agent.displayName);
+
// Chat session contribution.
// In the Agents app, hide the delegation picker for local agent host
// sessions (matches behavior of remote agent host sessions). In VS Code,
@@ -159,7 +167,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
store.add(this._chatSessionsService.registerChatSessionContribution({
type: sessionType,
name: agentId,
- displayName: agent.displayName,
+ displayName,
description: agent.description,
canDelegate: true,
requiresCustomModels: true,
diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts
index e6cbe34da226f..22e8a9a45d48d 100644
--- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts
+++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts
@@ -810,10 +810,19 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
}
this._contributions.set(contribution.type, { contribution, extension: undefined });
+ // Programmatically-registered contributions are always considered
+ // available; mark them as such so the autorun in the constructor
+ // registers the in-place "New {0} Session" action for them. Without
+ // this, types like `agent-host-copilotcli` (registered by the local
+ // agent host) have no `openNewChatSessionInPlace.` command.
+ this._contributionDisposables.set(contribution.type, new DisposableStore());
+ this._updateHasCanDelegateProvidersContextKey();
this._onDidChangeAvailability.fire();
return toDisposable(() => {
this._contributions.delete(contribution.type);
+ this._contributionDisposables.deleteAndDispose(contribution.type);
+ this._updateHasCanDelegateProvidersContextKey();
this._onDidChangeAvailability.fire();
});
}
diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts
index 56b30bea3d506..5089f6c6bb8ce 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts
@@ -140,6 +140,7 @@ const INPUT_EDITOR_LINE_HEIGHT = 20;
const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 12, bottom: 12 } };
const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2';
const CHAT_INPUT_PICKER_COLLAPSE_WIDTH = 480;
+const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
export interface IChatInputStyles {
overlayBackground: string;
@@ -821,6 +822,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this._currentPermissionLevel.set(level, undefined);
this.permissionLevelKey.set(level);
this.permissionWidget?.refresh();
+ const sessionResource = this.getCurrentSessionResource();
+ if (sessionResource) {
+ this.chatSessionsService.setSessionOption(sessionResource, PERMISSION_LEVEL_OPTION_ID, level);
+ }
this._syncInputStateToModel();
}
diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts
index f44449e2ddc66..e803859df9d5d 100644
--- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts
+++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts
@@ -172,8 +172,8 @@ export class PromptValidator {
const loc = this.labelService.getUriLabel(resolved);
report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found at '{1}'.", ref.content, loc), ref.range, MarkerSeverity.Warning));
}
- } catch {
- this.logger.warn(`Error checking existence of file reference '${ref.content}' resolved to '${resolved.toString()}' in prompt file '${promptAST.uri.toString()}'`);
+ } catch (e) {
+ this.logger.warn(`Error checking existence of file reference '${ref.content}' resolved to '${resolved.toString()}' in prompt file '${promptAST.uri.toString()}': ${e.message}`);
}
})());
}
diff --git a/src/vs/workbench/services/secrets/electron-browser/secretStorageService.ts b/src/vs/workbench/services/secrets/electron-browser/secretStorageService.ts
index 228328d59bb1a..8763ad91df807 100644
--- a/src/vs/workbench/services/secrets/electron-browser/secretStorageService.ts
+++ b/src/vs/workbench/services/secrets/electron-browser/secretStorageService.ts
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { createSingleCallFunction } from '../../../../base/common/functional.js';
-import { isLinux } from '../../../../base/common/platform.js';
+import { isLinux, isMacintosh } from '../../../../base/common/platform.js';
import Severity from '../../../../base/common/severity.js';
import { localize } from '../../../../nls.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
@@ -14,7 +14,8 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta
import { ILogService } from '../../../../platform/log/common/log.js';
import { INotificationService, IPromptChoice } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
-import { BaseSecretStorageService, ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
+import { BaseSecretStorageService, CROSS_APP_SHARED_SECRET_KEYS, ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
+import { ISharedKeychainService } from '../../../../platform/secrets/common/sharedKeychainService.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { IJSONEditingService } from '../../configuration/common/jsonEditing.js';
@@ -26,6 +27,7 @@ export class NativeSecretStorageService extends BaseSecretStorageService {
@IOpenerService private readonly _openerService: IOpenerService,
@IJSONEditingService private readonly _jsonEditingService: IJSONEditingService,
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
+ @ISharedKeychainService private readonly _sharedKeychainService: ISharedKeychainService,
@IStorageService storageService: IStorageService,
@IEncryptionService encryptionService: IEncryptionService,
@ILogService logService: ILogService
@@ -38,6 +40,20 @@ export class NativeSecretStorageService extends BaseSecretStorageService {
);
}
+ override get(key: string): Promise {
+ return this._sequencer.queue(key, async () => {
+ if (isMacintosh && this.type !== 'in-memory' && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
+ // Try shared keychain first
+ const value = await this._sharedKeychainService.get(key);
+ if (value !== undefined) {
+ return value;
+ }
+ }
+ // Fall back to old safeStorage+SQLite pipeline
+ return this._doGet(key);
+ });
+ }
+
override set(key: string, value: string): Promise {
this._sequencer.queue(key, async () => {
await this.resolvedStorageService;
@@ -46,10 +62,42 @@ export class NativeSecretStorageService extends BaseSecretStorageService {
this._logService.trace('[NativeSecretStorageService] Notifying user that secrets are not being stored on disk.');
await this.notifyOfNoEncryptionOnce();
}
+ });
+ return this._sequencer.queue(key, async () => {
+ if (isMacintosh && this.type !== 'in-memory' && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
+ // Write to shared keychain
+ await this._sharedKeychainService.set(key, value);
+ }
+ // Also write to legacy pipeline
+ await this._doSet(key, value);
+ });
+ }
+ override delete(key: string): Promise {
+ return this._sequencer.queue(key, async () => {
+ if (isMacintosh && this.type !== 'in-memory' && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
+ // Delete from shared keychain
+ await this._sharedKeychainService.delete(key);
+ }
+ // Delete from legacy pipeline
+ await this._doDelete(key);
});
+ }
- return super.set(key, value);
+ override async keys(): Promise {
+ return this._sequencer.queue('__keys__', async () => {
+ const legacyKeys = await this._doGetKeys();
+ if (isMacintosh && this.type !== 'in-memory') {
+ // Include any cross-app shared keys present in the shared keychain
+ for (const sharedKey of CROSS_APP_SHARED_SECRET_KEYS) {
+ const sharedValue = await this._sharedKeychainService.get(sharedKey);
+ if (sharedValue !== undefined && !legacyKeys.includes(sharedKey)) {
+ legacyKeys.push(sharedKey);
+ }
+ }
+ }
+ return legacyKeys;
+ });
}
private notifyOfNoEncryptionOnce = createSingleCallFunction(() => this.notifyOfNoEncryption());
diff --git a/src/vs/workbench/services/secrets/electron-browser/sharedKeychainService.ts b/src/vs/workbench/services/secrets/electron-browser/sharedKeychainService.ts
new file mode 100644
index 0000000000000..7930df4d69341
--- /dev/null
+++ b/src/vs/workbench/services/secrets/electron-browser/sharedKeychainService.ts
@@ -0,0 +1,9 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { registerMainProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js';
+import { ISharedKeychainService } from '../../../../platform/secrets/common/sharedKeychainService.js';
+
+registerMainProcessRemoteService(ISharedKeychainService, 'sharedKeychain');
diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts
index 4277093b10389..bbf0685647750 100644
--- a/src/vs/workbench/workbench.desktop.main.ts
+++ b/src/vs/workbench/workbench.desktop.main.ts
@@ -59,6 +59,7 @@ import './services/mcp/electron-browser/mcpWorkbenchManagementService.js';
import './services/encryption/electron-browser/encryptionService.js';
import './services/imageResize/electron-browser/imageResizeService.js';
import './services/secrets/electron-browser/secretStorageService.js';
+import './services/secrets/electron-browser/sharedKeychainService.js';
import './services/localization/electron-browser/languagePackService.js';
import './services/telemetry/electron-browser/telemetryService.js';
import './services/extensions/electron-browser/extensionHostStarter.js';