Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
@@ -0,0 +1,189 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { localize } from '../../../../../nls.js';
import { AgentHostAllowSignedOutWhenUsableSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { IWorkbenchContribution } from '../../../../../workbench/common/contributions.js';
import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js';
import { hasVisibleByokModelsTargetingSessionType } from '../../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js';
import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js';
import { localChatSessionType, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { MANAGE_CHAT_COMMAND_ID } from '../../../../../workbench/contrib/chat/common/constants.js';
import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
import { ILanguageModelsConfigurationService } from '../../../../../workbench/contrib/chat/common/languageModelsConfiguration.js';

const SIGNED_OUT_MODELS_NOTIFICATION_ID = 'agentHost.signedOutModels.copilot';
const SIGNED_OUT_LOCAL_MODELS_NOTIFICATION_ID = 'local.signedOutModels';
const SIGN_IN_COMMAND_ID = 'workbench.action.chat.triggerSetup';
const COPILOT_AGENT_HOST_PROVIDER_ID = 'copilotcli';
const COPILOT_MODEL_TARGETS = [SessionType.AgentHostCopilot, SessionType.CopilotCLI];

export function shouldShowSignedOutModelsNotification(allowSignedOutWhenUsable: boolean, modelsLoaded: boolean, accountResolved: boolean, signedIn: boolean, hasModels: boolean): boolean {
return allowSignedOutWhenUsable && modelsLoaded && accountResolved && !signedIn && !hasModels;
}

export function areLocalModelsLoaded(extensionsRegistered: boolean, configurationLoaded: boolean, configuredByokVendors: readonly string[], hasResolvedVendor: (vendor: string) => boolean): boolean {
return extensionsRegistered && configurationLoaded && configuredByokVendors.every(hasResolvedVendor);
}

export function hasAvailableAgentHostByokModels(hasTargetedModels: boolean, hasSourceModels: boolean): boolean {
return hasTargetedModels || hasSourceModels;
}

/**
* Shows harness-scoped guidance when signed-out operation is enabled but the selected harness has no usable models.
*/
export class AgentHostSignedOutModelsNotificationContribution extends Disposable implements IWorkbenchContribution {

static readonly ID = 'sessions.contrib.agentHostSignedOutModelsNotification';

private readonly _shown = new Set<string>();
/** Startup readiness prevents transient empty catalogs from producing false notifications. */
private _accountResolved = false;
private _extensionsRegistered = false;
private _configurationLoaded = false;

constructor(
@IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService,
@IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService,
@ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService,
@ILanguageModelsConfigurationService private readonly _languageModelsConfigurationService: ILanguageModelsConfigurationService,
@IAgentHostService private readonly _agentHostService: IAgentHostService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IExtensionService extensionService: IExtensionService,
) {
super();

// Reconcile whenever auth, model registration/visibility, configuration, or host state can change the answer.
this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => {
this._accountResolved = true;
this._update();
}));
this._defaultAccountService.getDefaultAccount().then(() => {
if (!this._store.isDisposed) {
this._accountResolved = true;
this._update();
}
});
this._register(this._languageModelsService.onDidChangeLanguageModels(() => this._update()));
this._register(this._languageModelsService.onDidChangeModelVisibility(() => this._update()));
this._register(this._languageModelsConfigurationService.onDidChangeLanguageModelGroups(() => this._update()));
this._register(this._configurationService.onDidChangeConfiguration(event => {
if (event.affectsConfiguration(AgentHostAllowSignedOutWhenUsableSettingId)) {
this._update();
}
}));
extensionService.whenInstalledExtensionsRegistered().then(() => {
if (!this._store.isDisposed) {
this._extensionsRegistered = true;
this._update();
}
});
this._languageModelsConfigurationService.whenReady.then(() => {
if (!this._store.isDisposed) {
this._configurationLoaded = true;
this._update();
}
});
const rootStateListeners = this._register(new DisposableStore());
const bindRootState = () => {
rootStateListeners.clear();
rootStateListeners.add(this._agentHostService.rootState.onDidChange(() => this._update()));
if (this._agentHostService.rootState.onDidError) {
rootStateListeners.add(this._agentHostService.rootState.onDidError(() => this._update()));
}
this._update();
};
bindRootState();
this._register(this._agentHostService.onAgentHostStart(bindRootState));
}

private _update(): void {
// Local BYOK readiness is shared by both harnesses; Agent Host additionally waits for its bridged catalog.
const allowSignedOutWhenUsable = this._configurationService.getValue<boolean>(AgentHostAllowSignedOutWhenUsableSettingId) === true;
const signedIn = this._defaultAccountService.currentDefaultAccount !== null;
const configuredByokVendors = new Set(this._languageModelsConfigurationService.getLanguageModelsProviderGroups()
.map(group => group.vendor)
.filter(vendor => vendor !== COPILOT_VENDOR_ID));
const byokModelsLoaded = areLocalModelsLoaded(
this._extensionsRegistered,
this._configurationLoaded,
[...configuredByokVendors],
vendor => this._languageModelsService.hasResolvedVendor(vendor),
);
const rootState = this._agentHostService.rootState.value;
const agentHostModelsLoaded = byokModelsLoaded
&& !!rootState
&& !(rootState instanceof Error)
&& rootState.agents.some(agent => agent.provider === COPILOT_AGENT_HOST_PROVIDER_ID)
&& this._languageModelsService.hasResolvedVendor(SessionType.AgentHostCopilot);
const hasVisibleLocalByokModels = this._languageModelsService.getLanguageModelIds().some(identifier => {
const metadata = this._languageModelsService.lookupLanguageModel(identifier);
return metadata?.isBYOK === true
&& !metadata.targetChatSessionType
&& !this._languageModelsService.isModelHidden(identifier);
Comment thread
vritant24 marked this conversation as resolved.
Outdated
});
const hasVisibleAgentHostByokModels = hasAvailableAgentHostByokModels(
hasVisibleByokModelsTargetingSessionType(this._languageModelsService, SessionType.AgentHostCopilot),
hasVisibleLocalByokModels,
);
this._setNotification(
SIGNED_OUT_MODELS_NOTIFICATION_ID,
shouldShowSignedOutModelsNotification(allowSignedOutWhenUsable, agentHostModelsLoaded, this._accountResolved, signedIn, hasVisibleAgentHostByokModels),
COPILOT_MODEL_TARGETS,
);

this._setNotification(
SIGNED_OUT_LOCAL_MODELS_NOTIFICATION_ID,
shouldShowSignedOutModelsNotification(allowSignedOutWhenUsable, byokModelsLoaded, this._accountResolved, signedIn, hasVisibleLocalByokModels),
[localChatSessionType],
);
}

private _setNotification(id: string, show: boolean, sessionTypes: readonly string[]): void {
// Reconcile by stable id so unrelated input notifications and user interaction state are preserved.
if (!show) {
if (this._shown.delete(id)) {
this._chatInputNotificationService.deleteNotification(id);
}
return;
}
if (this._shown.has(id)) {
return;
}

this._shown.add(id);
this._chatInputNotificationService.setNotification(this._createNotification(id, sessionTypes));
}

private _createNotification(id: string, sessionTypes: readonly string[]): IChatInputNotification {
return {
id,
severity: ChatInputNotificationSeverity.Info,
message: localize('agentHost.signedOutModels.message', "Choose how you want to use Copilot."),
description: localize('agentHost.signedOutModels.description', "Sign in to use GitHub Copilot models, or add a model with your own API key."),
actions: [
{
kind: ChatInputNotificationActionKind.Command,
label: localize('agentHost.signedOutModels.addModels', "Add Models"),
commandId: MANAGE_CHAT_COMMAND_ID,
keepOpen: true,
},
{
kind: ChatInputNotificationActionKind.Command,
label: localize('agentHost.signedOutModels.signIn', "Sign In"),
commandId: SIGN_IN_COMMAND_ID,
keepOpen: true,
}
],
dismissible: false,
autoDismissOnMessage: false,
sessionTypes,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export const CopilotCLISessionType: ISessionType = {
label: localize('copilotCLI', "Copilot"),
icon: Codicon.copilot,
supportsWorktreeConfiguration: true,
authRequirement: SessionTypeAuthRequirement.GitHub,
authRequirement: SessionTypeAuthRequirement.None,
};

/**
Expand Down 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 './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);
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co
import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js';
import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js';
import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js';
import { IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js';
import { IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { IGitHubService } from '../../../github/browser/githubService.js';
import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js';
Expand Down Expand Up @@ -69,6 +69,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide
return -1;
}

protected override _resolveAgentAuthRequirement(): SessionTypeAuthRequirement {
return SessionTypeAuthRequirement.None;
}

constructor(
@IAgentHostService private readonly _agentHostService: IAgentHostService,
@IChatSessionsService chatSessionsService: IChatSessionsService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/co
import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js';
import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js';
import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js';
import { ChatInteractivity, ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js';
import { ChatInteractivity, ChatOriginKind, getChatCapabilities, ISession, SessionStatus, SessionTypeAuthRequirement } from '../../../../../services/sessions/common/session.js';
import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js';
import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js';
import { IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js';
Expand Down Expand Up @@ -583,6 +583,7 @@ suite('LocalAgentHostSessionsProvider', () => {
// local and remote hosts and the standalone Copilot CLI provider.
assert.strictEqual(provider.sessionTypes[0].id, 'copilotcli');
assert.strictEqual(provider.sessionTypes[0].label, 'Copilot');
assert.strictEqual(provider.sessionTypes[0].authRequirement, SessionTypeAuthRequirement.None);
});

test('session types update when the local host advertises additional agents', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../../../platform/ag
import type { AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import type { ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { resolveAgentAuthRequirement } from '../../browser/baseAgentHostSessionsProvider.js';
import { areLocalModelsLoaded, hasAvailableAgentHostByokModels, shouldShowSignedOutModelsNotification } from '../../browser/agentHostSignedOutModelsNotification.js';
import { SessionTypeAuthRequirement } from '../../../../../services/sessions/common/session.js';

function agent(protectedResources: ProtectedResourceMetadata[] | undefined, modelCount: number): AgentInfo {
Expand Down Expand Up @@ -66,4 +67,45 @@ suite('Agent Host - session type auth requirement', () => {

assert.deepStrictEqual(usable, [true, false, false]);
});

test('no-model notification follows sign-in and model availability', () => {
assert.deepStrictEqual({
featureDisabled: shouldShowSignedOutModelsNotification(false, true, true, false, false),
loadingSignedOutWithoutModelsNotification: shouldShowSignedOutModelsNotification(true, false, true, false, false),
unresolvedAccountNotification: shouldShowSignedOutModelsNotification(true, true, false, false, false),
signedOutWithoutModelsNotification: shouldShowSignedOutModelsNotification(true, true, true, false, false),
signedOutWithModelsNotification: shouldShowSignedOutModelsNotification(true, true, true, false, true),
signedOutWithTargetedByokNotification: shouldShowSignedOutModelsNotification(true, true, true, false, hasAvailableAgentHostByokModels(true, false)),
signedOutWithSourceByokNotification: shouldShowSignedOutModelsNotification(true, true, true, false, hasAvailableAgentHostByokModels(false, true)),
signedInWithoutModelsNotification: shouldShowSignedOutModelsNotification(true, true, true, true, false),
}, {
featureDisabled: false,
loadingSignedOutWithoutModelsNotification: false,
unresolvedAccountNotification: false,
signedOutWithoutModelsNotification: true,
signedOutWithModelsNotification: false,
signedOutWithTargetedByokNotification: false,
signedOutWithSourceByokNotification: false,
signedInWithoutModelsNotification: false,
});
});

test('No-model notifications wait for extension, configuration, and provider discovery', () => {
const resolvedVendors = new Set(['anthropic']);
const hasResolvedVendor = (vendor: string) => resolvedVendors.has(vendor);

assert.deepStrictEqual({
extensionsLoading: areLocalModelsLoaded(false, true, [], hasResolvedVendor),
configurationLoading: areLocalModelsLoaded(true, false, [], hasResolvedVendor),
providerLoading: areLocalModelsLoaded(true, true, ['anthropic', 'openai'], hasResolvedVendor),
settledEmpty: areLocalModelsLoaded(true, true, [], hasResolvedVendor),
settledConfigured: areLocalModelsLoaded(true, true, ['anthropic'], hasResolvedVendor),
}, {
extensionsLoading: false,
configurationLoading: false,
providerLoading: false,
settledEmpty: true,
settledConfigured: true,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,15 @@ export const ClaudeCodeSessionType: ISessionType = {
id: 'claude-code',
label: localize('claudeCode', "Claude"),
icon: Codicon.claude,
// Extension-contributed (legacy) generation: no native mode, always Copilot-backed.
authRequirement: SessionTypeAuthRequirement.GitHub,
authRequirement: SessionTypeAuthRequirement.None,
Comment thread
vritant24 marked this conversation as resolved.
Outdated
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
};

/** Copilot Cloud session type - cloud-hosted agent. */
export const CopilotCloudSessionType: ISessionType = {
id: 'copilot-cloud-agent',
label: localize('copilotCloud', "Cloud"),
icon: Codicon.cloud,
authRequirement: SessionTypeAuthRequirement.GitHub,
authRequirement: SessionTypeAuthRequirement.None,
};

const SESSION_WORKSPACE_GROUP_GITHUB = localize('sessionWorkspaceGroup.github', "GitHub");
Expand Down
Loading
Loading