From 5867b9ed8d4b45c942e5dc6c0fe4bd3b550b04fb Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 4 Sep 2026 17:18:06 -0700 Subject: [PATCH] agentHost: Adapt SDK download notification to available models Explain download-on-use when models are available, react to model availability changes, and preserve the Download action and no-model wording. Fixes #334668 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostSdkSetupNotification.ts | 11 +- .../agentHostSdkSetupNotification.test.ts | 116 +++++++++++++++++- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts index 9da13b836f3ef..df85edec01423 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts @@ -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. @@ -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)], }; } @@ -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. @@ -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; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts index 17ee96cdc59ad..5a502fed45ad1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts @@ -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 = { @@ -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 }[] = [ @@ -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' }; @@ -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()); + const models = new Map(); + 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() { + 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() { + 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({