Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const NOTIFICATION_ID = 'copilot.byokUtilityModelHint';
const UTILITY_MODEL_SETTING = 'chat.utilityModel';
const UTILITY_SMALL_MODEL_SETTING = 'chat.utilitySmallModel';
const BYOK_UTILITY_MODEL_DEFAULT_SETTING = 'chat.byokUtilityModelDefault';
const ALLOW_SIGNED_OUT_WHEN_USABLE_SETTING = 'chat.agentHost.allowSignedOutWhenUsable';
const MAIN_AGENT_BYOK_UTILITY_MODEL_DEFAULT = 'mainAgent';
const LOCAL_CHAT_SESSION_TYPE = 'local';

/**
* Shows a chat input notification in air-gapped BYOK scenarios (no GitHub
Expand Down Expand Up @@ -48,6 +50,7 @@ export class ByokUtilityModelNotificationContribution extends Disposable {
e.affectsConfiguration(UTILITY_MODEL_SETTING)
|| e.affectsConfiguration(UTILITY_SMALL_MODEL_SETTING)
|| e.affectsConfiguration(BYOK_UTILITY_MODEL_DEFAULT_SETTING)
|| e.affectsConfiguration(ALLOW_SIGNED_OUT_WHEN_USABLE_SETTING)
) {
this._update();
}
Expand Down Expand Up @@ -97,6 +100,9 @@ export class ByokUtilityModelNotificationContribution extends Disposable {
notification.severity = vscode.ChatInputNotificationSeverity.Info;
notification.dismissible = true;
notification.autoDismissOnMessage = false;
notification.sessionTypes = this._configService.getNonExtensionConfig<boolean>(ALLOW_SIGNED_OUT_WHEN_USABLE_SETTING)
? [LOCAL_CHAT_SESSION_TYPE]
: undefined;

if (utilityUnset && utilitySmallUnset) {
notification.message = vscode.l10n.t('Set BYOK utility models');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const mockNotification = {
message: '',
description: '',
actions: [] as { label: string; commandId: string; commandArgs?: unknown[] }[],
sessionTypes: undefined as readonly string[] | undefined,
show: vi.fn(),
hide: vi.fn(),
dispose: vi.fn(),
Expand Down Expand Up @@ -99,6 +100,7 @@ describe('ByokUtilityModelNotificationContribution', () => {
mockNotification.message = '';
mockNotification.description = '';
mockNotification.actions = [];
mockNotification.sessionTypes = undefined;
mockWorkspace.isAgentSessionsWorkspace = false;
selectChatModelsMock.mockResolvedValue([{ vendor: 'ollama', id: 'llama3' }]);
});
Expand All @@ -110,7 +112,7 @@ describe('ByokUtilityModelNotificationContribution', () => {

test('shows notification when signed out + BYOK + both utility settings unset', async () => {
const { authService } = createAuthService({ anyGitHubSession: undefined });
const { configService } = createConfigService();
const { configService } = createConfigService({ 'chat.agentHost.allowSignedOutWhenUsable': true });
contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog);

await flushAsync();
Expand All @@ -120,6 +122,18 @@ describe('ByokUtilityModelNotificationContribution', () => {
expect(mockNotification.actions).toHaveLength(1);
expect(mockNotification.actions[0].commandId).toBe('workbench.action.openSettings');
expect(mockNotification.actions[0].commandArgs).toEqual(['chat.byokUtilityModelDefault']);
expect(mockNotification.sessionTypes).toEqual(['local']);
});

test('keeps the notification global when signed-out operation is disabled', async () => {
const { authService } = createAuthService({ anyGitHubSession: undefined });
const { configService } = createConfigService();
contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog);

await flushAsync();

expect(mockNotification.show).toHaveBeenCalled();
expect(mockNotification.sessionTypes).toBeUndefined();
});

test('does not show notification in the Agents window', async () => {
Expand Down
6 changes: 5 additions & 1 deletion src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,8 @@ export class CopilotAgent extends Disposable implements IAgent {
// startup changes, disposing any active sessions. These values are applied in
// `_ensureClient`, so they only take effect on the next client start.
this._register(this._configurationService.onDidRootConfigChange(() => {
// Protected-resource optionality depends on the signed-out opt-in.
this._publishModels();
this._restartClientIfStartupConfigChanged().catch(err =>
this._logService.error('[Copilot] Failed to apply root config change', err)
);
Expand Down Expand Up @@ -993,8 +995,10 @@ export class CopilotAgent extends Disposable implements IAgent {
}

getProtectedResources(): ProtectedResourceMetadata[] {
const allowSignedOutWhenUsable = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true;
const copilotResource = this._gitHubEndpointService.getCopilotResource();
return [
this._gitHubEndpointService.getCopilotResource(),
allowSignedOutWhenUsable && this._byokModels.length > 0 ? { ...copilotResource, required: false } : copilotResource,
this._gitHubEndpointService.getRepoResource(),
];
}
Expand Down
39 changes: 39 additions & 0 deletions src/vs/platform/agentHost/test/node/copilotAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilo
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js';
import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentCreateChatForkSource, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js';
import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js';
import { CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
Expand Down Expand Up @@ -2574,6 +2575,44 @@ suite('CopilotAgent', () => {
}
});

test('BYOK models make Copilot authentication optional only while signed-out operation is enabled', async () => {
const byokBridgeRegistry = new ByokLmBridgeRegistry();
const { agent, configurationService } = createTestAgentContext(disposables, { byokBridgeRegistry });
const modelSnapshots = disposables.add(new Emitter<IByokLmModelInfo[]>());
const connection: IByokLmBridgeConnection = {
chat: async () => ({ output: [] }),
onDidChangeModels: modelSnapshots.event,
};
disposables.add(byokBridgeRegistry.register('renderer', connection));
const copilotRequired = () => agent.getProtectedResources()
.find(resource => resource.resource === GITHUB_COPILOT_PROTECTED_RESOURCE.resource)?.required !== false;

try {
const initiallyRequired = copilotRequired();
configurationService.updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: true });
const requiredWithoutByok = copilotRequired();
modelSnapshots.fire([{ vendor: 'gemini', id: 'gemini-2.5-pro', modelIdentifier: 'gemini/Gemini/gemini-2.5-pro' }]);
await waitForState(agent.models, models => models.length === 1);
const optionalWithByok = copilotRequired();
configurationService.updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: false });
const requiredAfterDisable = copilotRequired();

assert.deepStrictEqual({
initiallyRequired,
requiredWithoutByok,
optionalWithByok,
requiredAfterDisable,
}, {
initiallyRequired: true,
requiredWithoutByok: true,
optionalWithByok: false,
requiredAfterDisable: true,
});
} finally {
await disposeAgent(agent);
}
});

test('BYOK models from multiple Gemini provider groups have unique picker identifiers', async () => {
const byokBridgeRegistry = new ByokLmBridgeRegistry();
const agent = createTestAgent(disposables, { byokBridgeRegistry });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele
import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js';
import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
import { getSessionTypeAvailability, getSessionTypeUnavailableLabel, SessionTypeAvailability } from '../../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js';
import { getSessionTypeAvailability, getSessionTypePickerAvailability, getSessionTypeUnavailableLabel, SessionTypeAvailability } from '../../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js';
import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js';
import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
Expand All @@ -20,6 +20,8 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co
import { SessionTypePicker, ISessionTypePickerOptions } from '../sessionTypePicker.js';
import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js';
import { IMobilePickerSheetItem, showMobilePickerSheet } from '../../../../browser/parts/mobile/mobilePickerSheet.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { isAllowSignedOutWhenUsableEnabled } from '../../../../browser/sessionsAuthGate.js';

/**
* Phone variant of {@link SessionTypePicker} that renders the picker as
Expand All @@ -45,10 +47,11 @@ export class MobileSessionTypePicker extends SessionTypePicker {
@IChatSessionsService chatSessionsService: IChatSessionsService,
@IChatEntitlementService chatEntitlementService: IChatEntitlementService,
@ILanguageModelsService languageModelsService: ILanguageModelsService,
@IConfigurationService configurationService: IConfigurationService,
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super(session, options, actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService, chatSessionsService, chatEntitlementService, languageModelsService, contextKeyService);
super(session, options, actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService, chatSessionsService, chatEntitlementService, languageModelsService, configurationService, contextKeyService);
}

override render(container: HTMLElement, options?: { className?: string }): void {
Expand Down Expand Up @@ -95,7 +98,13 @@ export class MobileSessionTypePicker extends SessionTypePicker {
for (const [groupTitle, types] of groups) {
let isFirstInGroup = true;
for (const { providerId, sessionType } of types) {
const availability = getSessionTypeAvailability(this.chatSessionsService, this.chatEntitlementService, this.languageModelsService, sessionType.chatSessionType ?? sessionType.id);
const modelTarget = sessionType.chatSessionType ?? sessionType.id;
const allowSignedOutWhenUsable = isAllowSignedOutWhenUsableEnabled(this.configurationService);
const availability = getSessionTypePickerAvailability(
modelTarget,
getSessionTypeAvailability(this.chatSessionsService, this.chatEntitlementService, this.languageModelsService, modelTarget, allowSignedOutWhenUsable),
allowSignedOutWhenUsable,
);
sheetItems.push({
id: `${providerId}\u0000${sessionType.id}`,
label: sessionType.label,
Expand Down
13 changes: 11 additions & 2 deletions src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '..
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
Expand All @@ -24,11 +25,12 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
import { getSessionTypeAvailability, getSessionTypeUnavailableDescription, getSessionTypeUnavailableHover, SessionTypeAvailability } from '../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js';
import { getSessionTypeAvailability, getSessionTypePickerAvailability, getSessionTypeUnavailableDescription, getSessionTypeUnavailableHover, SessionTypeAvailability } from '../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js';
import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js';
import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js';
import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js';
import { SessionHarnessPickerVisibleContext } from '../../../common/contextkeys.js';
import { isAllowSignedOutWhenUsableEnabled } from '../../../browser/sessionsAuthGate.js';

const STORAGE_KEY_LAST_SESSION_TYPE = 'sessions.userSelectedSessionType';

Expand Down Expand Up @@ -164,6 +166,7 @@ export class SessionTypePicker extends Disposable {
@IChatSessionsService protected readonly chatSessionsService: IChatSessionsService,
@IChatEntitlementService protected readonly chatEntitlementService: IChatEntitlementService,
@ILanguageModelsService protected readonly languageModelsService: ILanguageModelsService,
@IConfigurationService protected readonly configurationService: IConfigurationService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super();
Expand Down Expand Up @@ -453,7 +456,13 @@ export class SessionTypePicker extends Disposable {
}
for (const { providerId, sessionType } of types) {
const isCurrent = this._picked?.providerId === providerId && this._picked?.sessionTypeId === sessionType.id;
const availability = getSessionTypeAvailability(this.chatSessionsService, this.chatEntitlementService, this.languageModelsService, sessionType.chatSessionType ?? sessionType.id);
const modelTarget = sessionType.chatSessionType ?? sessionType.id;
const allowSignedOutWhenUsable = isAllowSignedOutWhenUsableEnabled(this.configurationService);
const availability = getSessionTypePickerAvailability(
modelTarget,
getSessionTypeAvailability(this.chatSessionsService, this.chatEntitlementService, this.languageModelsService, modelTarget, allowSignedOutWhenUsable),
allowSignedOutWhenUsable,
);
const unavailable = availability !== SessionTypeAvailability.Available;
const item: ISessionTypePickerItem = {
providerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { autorun, constObservable, ISettableObservable, observableValue } from '
import { URI } from '../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
Expand Down Expand Up @@ -134,6 +136,7 @@ function createPicker(
getLanguageModelIds: () => [],
lookupLanguageModel: () => undefined,
});
instantiationService.stub(IConfigurationService, new TestConfigurationService());
instantiationService.stub(IContextKeyService, new MockContextKeyService());
return disposables.add(instantiationService.createInstance(TestSessionTypePicker, session, options));
}
Expand All @@ -143,6 +146,7 @@ function createPicker(
suite('SessionTypePicker', () => {

const disposables = new DisposableStore();

const folder = URI.file('/project');

let management: MockSessionsManagementService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2477,7 +2477,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
.map((agent): ISessionType => ({
id: agent.provider,
supportsWorktreeConfiguration: agent.provider === CopilotCLISessionType.id,
authRequirement: resolveAgentAuthRequirement(agent),
authRequirement: this._resolveAgentAuthRequirement(agent),
// The chat session contribution and language models for an agent-host
// agent are registered under its resource scheme (`agent-host-<provider>`),
// not the bare provider id, so carry it for availability lookups.
Expand All @@ -2494,6 +2494,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
this._onDidChangeSessionTypes.fire();
}

protected _resolveAgentAuthRequirement(agent: AgentInfo): SessionTypeAuthRequirement {
return resolveAgentAuthRequirement(agent);
}

/**
* Returns the {@link ThemeIcon} associated with a known agent provider, or
* `undefined` when the provider is not recognised.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../../workbe
import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js';
import { AgentHostAllowSignedOutWhenUsableContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAllowSignedOutWhenUsableContribution.js';
import { AgentHostDiscoveredConfigNotificationContribution } from './agentHostDiscoveredConfigNotification.js';
import { AgentHostSignedOutModelsNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSignedOutModelsNotification.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { SessionStatus } from '../../../../services/sessions/common/session.js';
import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js';
Expand Down Expand Up @@ -81,4 +82,5 @@ registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution,
registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(AgentHostAllowSignedOutWhenUsableContribution.ID, AgentHostAllowSignedOutWhenUsableContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(AgentHostDiscoveredConfigNotificationContribution.ID, AgentHostDiscoveredConfigNotificationContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(AgentHostSignedOutModelsNotificationContribution.ID, AgentHostSignedOutModelsNotificationContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(LocalAgentHostContribution.ID, LocalAgentHostContribution, WorkbenchPhase.AfterRestored);
Loading
Loading