Skip to content
Merged
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 @@ -184,7 +184,7 @@ export function hasAgentSdkSetupNotification(chatInputNotificationService: IChat
* never tie the SDK to an account: it is the same SDK behind the Copilot proxy,
* a subscription or a BYO key.
*/
export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displayName: string, state: AgentSdkSetupState | undefined): IChatInputNotification | undefined {
export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displayName: string, state: AgentSdkSetupState | undefined, hasModels = false): IChatInputNotification | undefined {
// Nothing to ask of a user who is already set up. An empty `displayName` means
// the host has not described this agent yet, and "Download the Agent" is worse
// than none; the next root-state change is moments away.
Expand All @@ -209,7 +209,9 @@ export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displ
return {
...base,
message: localize('agentHost.sdkSetup.download', "Download the {0} Agent", displayName),
description: localize('agentHost.sdkSetup.downloadDescription', "To use the {0} Agent, we need to download the {0} Agent SDK.", displayName),
description: hasModels
? localize('agentHost.sdkSetup.downloadDescription.withModels', "Click Download or send a message to download the {0} Agent SDK.", displayName)
: localize('agentHost.sdkSetup.downloadDescription', "To use the {0} Agent, we need to download the {0} Agent SDK.", displayName),
actions: [action(localize('agentHost.sdkSetup.downloadAction', "Download"), AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID)],
};
}
Expand Down Expand Up @@ -323,13 +325,14 @@ export class AgentHostSdkSetupNotificationContribution extends Disposable implem
if (!displayName) {
continue;
}
const hasModels = hasAnyModelTargetingSessionType(this._languageModelsService, agentSdkSetupSessionType(setup.agent));
const state = getAgentSdkSetupState({
allowSignedOutWhenUsable,
signedIn,
entitlementResolved,
download: setup.download,
downloadRequested: this._agentSdkSetupService.isDownloadPending(setup.agent),
hasModels: hasAnyModelTargetingSessionType(this._languageModelsService, agentSdkSetupSessionType(setup.agent)),
hasModels,
});
// Before the render decision below, because `resolved` — the step the
// funnel exists to count — is exactly the state that renders nothing.
Expand All @@ -338,7 +341,7 @@ export class AgentHostSdkSetupNotificationContribution extends Disposable implem
this._lastReported.set(setup.agent, toReport);
this._agentSdkSetupService.reportSetupState(setup.agent, toReport);
}
const notification = createAgentSdkSetupNotification(setup, displayName, state);
const notification = createAgentSdkSetupNotification(setup, displayName, state, hasModels);
if (!notification) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { mock } from '../../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js';
import type { IAgentSdkSetupInfo } from '../../../../../../platform/agentHost/common/agentSdkSetup.js';
import { AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_RELOAD_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, agentSdkSetupNotificationId, createAgentSdkSetupNotification, getAgentDisplayNames, getAgentSdkSetupState, getAgentSdkSetupStateToReport, hasAgentSdkSetupNotification, type IAgentSdkSetupStateInputs } from '../../../browser/agentSessions/agentHost/agentHostSdkSetupNotification.js';
import type { AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js';
import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, type IChatInputNotification, type IChatInputNotificationAction, type IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js';
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_RELOAD_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, AgentHostSdkSetupNotificationContribution, agentSdkSetupNotificationId, createAgentSdkSetupNotification, getAgentDisplayNames, getAgentSdkSetupState, getAgentSdkSetupStateToReport, hasAgentSdkSetupNotification, type IAgentSdkSetupStateInputs } from '../../../browser/agentSessions/agentHost/agentHostSdkSetupNotification.js';
import { IAgentSdkSetupService, type AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js';
import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js';
import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotificationService, type IChatInputNotification, type IChatInputNotificationAction } from '../../../browser/widget/input/chatInputNotificationService.js';
import { SessionType } from '../../../common/chatSessionsService.js';
import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js';

/** Signed out, flag on, entitlement settled, SDK missing — the case this feature exists for. */
const BLOCKED_USER: IAgentSdkSetupStateInputs = {
Expand All @@ -27,7 +35,7 @@ function commandIds(actions: readonly IChatInputNotificationAction[]): string[]
}

suite('Agent SDK setup banner', () => {
ensureNoDisposablesAreLeakedInTestSuite();
const store = ensureNoDisposablesAreLeakedInTestSuite();

suite('state', () => {
const cases: readonly { readonly name: string; readonly inputs: IAgentSdkSetupStateInputs; readonly expected: AgentSdkSetupState | undefined }[] = [
Expand Down Expand Up @@ -78,6 +86,13 @@ suite('Agent SDK setup banner', () => {
assert.deepStrictEqual(notification.actions[0].kind === ChatInputNotificationActionKind.Command ? notification.actions[0].commandArgs : undefined, ['claude']);
});

test('models add the send-a-message option without changing the Download action', () => {
assert.deepStrictEqual(createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered', true), {
...createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered'),
description: 'Click Download or send a message to download the Claude Agent SDK.',
});
});

test('every noun comes from the agent, so a second agent needs no entry here', () => {
const codex: IAgentSdkSetupInfo = { agent: 'codex', download: 'notDownloaded', signInProviderName: 'ChatGPT' };

Expand Down Expand Up @@ -195,6 +210,99 @@ suite('Agent SDK setup banner', () => {
});
});

suite('model availability', () => {
function createFixture(initialSessionTypes: readonly (string | undefined)[] = []) {
const instantiationService = store.add(new TestInstantiationService());
const onDidChangeLanguageModels = store.add(new Emitter<string>());
const models = new Map<string, ILanguageModelChatMetadata>();
const notifications: IChatInputNotification[] = [];
const deletedNotifications: string[] = [];
const reportedStates: AgentSdkSetupState[] = [];
const setModels = (sessionTypes: readonly (string | undefined)[]) => {
models.clear();
for (const [index, targetChatSessionType] of sessionTypes.entries()) {
models.set(`model-${index}`, new class extends mock<ILanguageModelChatMetadata>() {
override readonly targetChatSessionType = targetChatSessionType;
}());
}
onDidChangeLanguageModels.fire('test');
};

instantiationService.stub(IChatInputNotificationService, {
setNotification: notification => notifications.push(notification),
deleteNotification: id => deletedNotifications.push(id),
});
instantiationService.stub(IAgentSdkSetupService, {
setups: [{ agent: 'claude', download: 'notDownloaded' }],
onDidChangeSetups: Event.None,
isDownloadPending: () => false,
reportSetupState: (_agent, state) => reportedStates.push(state),
});
instantiationService.stub(IDefaultAccountService, {
currentDefaultAccount: null,
onDidChangeDefaultAccount: Event.None,
});
instantiationService.stub(ILanguageModelsService, {
onDidChangeLanguageModels: onDidChangeLanguageModels.event,
getLanguageModelIds: () => [...models.keys()],
lookupLanguageModel: id => models.get(id),
});
instantiationService.stub(IConfigurationService, new TestConfigurationService());
instantiationService.stub(IChatEntitlementService, {
entitlement: ChatEntitlement.Pro,
onDidChangeEntitlement: Event.None,
});
instantiationService.stub(IAgentHostService, {
onAgentHostStart: Event.None,
rootState: new class extends mock<IAgentHostService['rootState']>() {
override readonly value = { agents: [{ provider: 'claude', displayName: 'Claude', description: '', models: [] }] };
override readonly onDidChange = Event.None;
}(),
});

setModels(initialSessionTypes);
store.add(instantiationService.createInstance(AgentHostSdkSetupNotificationContribution));

return { notifications, deletedNotifications, reportedStates, setModels };
}

test('explains download-on-use when models are already available', () => {
const fixture = createFixture([SessionType.AgentHostClaude]);

assert.deepStrictEqual(fixture.notifications.map(notification => notification.description), [
'Click Download or send a message to download the Claude Agent SDK.',
]);
});

test('updates the visible offer when models for its agent appear and disappear', () => {
const fixture = createFixture();
fixture.setModels([SessionType.AgentHostCodex, undefined]);
fixture.setModels([SessionType.AgentHostCodex, undefined, SessionType.AgentHostClaude]);
fixture.setModels([SessionType.AgentHostClaude]);
fixture.setModels([]);

assert.deepStrictEqual({
descriptions: fixture.notifications.map(notification => notification.description),
actions: fixture.notifications.map(notification => commandIds(notification.actions)),
deletedNotifications: fixture.deletedNotifications,
reportedStates: fixture.reportedStates,
}, {
descriptions: [
'To use the Claude Agent, we need to download the Claude Agent SDK.',
'Click Download or send a message to download the Claude Agent SDK.',
'To use the Claude Agent, we need to download the Claude Agent SDK.',
],
actions: [
[AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID],
[AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID],
[AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID],
],
deletedNotifications: [],
reportedStates: ['downloadOffered'],
});
});
});

suite('display names', () => {
test('reads each agent name the host published, and skips what it did not', () => {
assert.deepStrictEqual([...getAgentDisplayNames({
Expand Down
Loading