Skip to content

Commit 5867b9e

Browse files
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>
1 parent 7f82c18 commit 5867b9e

2 files changed

Lines changed: 119 additions & 8 deletions

File tree

src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export function hasAgentSdkSetupNotification(chatInputNotificationService: IChat
184184
* never tie the SDK to an account: it is the same SDK behind the Copilot proxy,
185185
* a subscription or a BYO key.
186186
*/
187-
export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displayName: string, state: AgentSdkSetupState | undefined): IChatInputNotification | undefined {
187+
export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displayName: string, state: AgentSdkSetupState | undefined, hasModels = false): IChatInputNotification | undefined {
188188
// Nothing to ask of a user who is already set up. An empty `displayName` means
189189
// the host has not described this agent yet, and "Download the Agent" is worse
190190
// than none; the next root-state change is moments away.
@@ -209,7 +209,9 @@ export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displ
209209
return {
210210
...base,
211211
message: localize('agentHost.sdkSetup.download', "Download the {0} Agent", displayName),
212-
description: localize('agentHost.sdkSetup.downloadDescription', "To use the {0} Agent, we need to download the {0} Agent SDK.", displayName),
212+
description: hasModels
213+
? localize('agentHost.sdkSetup.downloadDescription.withModels', "Click Download or send a message to download the {0} Agent SDK.", displayName)
214+
: localize('agentHost.sdkSetup.downloadDescription', "To use the {0} Agent, we need to download the {0} Agent SDK.", displayName),
213215
actions: [action(localize('agentHost.sdkSetup.downloadAction', "Download"), AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID)],
214216
};
215217
}
@@ -323,13 +325,14 @@ export class AgentHostSdkSetupNotificationContribution extends Disposable implem
323325
if (!displayName) {
324326
continue;
325327
}
328+
const hasModels = hasAnyModelTargetingSessionType(this._languageModelsService, agentSdkSetupSessionType(setup.agent));
326329
const state = getAgentSdkSetupState({
327330
allowSignedOutWhenUsable,
328331
signedIn,
329332
entitlementResolved,
330333
download: setup.download,
331334
downloadRequested: this._agentSdkSetupService.isDownloadPending(setup.agent),
332-
hasModels: hasAnyModelTargetingSessionType(this._languageModelsService, agentSdkSetupSessionType(setup.agent)),
335+
hasModels,
333336
});
334337
// Before the render decision below, because `resolved` — the step the
335338
// funnel exists to count — is exactly the state that renders nothing.
@@ -338,7 +341,7 @@ export class AgentHostSdkSetupNotificationContribution extends Disposable implem
338341
this._lastReported.set(setup.agent, toReport);
339342
this._agentSdkSetupService.reportSetupState(setup.agent, toReport);
340343
}
341-
const notification = createAgentSdkSetupNotification(setup, displayName, state);
344+
const notification = createAgentSdkSetupNotification(setup, displayName, state, hasModels);
342345
if (!notification) {
343346
continue;
344347
}

src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import assert from 'assert';
7+
import { Emitter, Event } from '../../../../../../base/common/event.js';
78
import { mock } from '../../../../../../base/test/common/mock.js';
89
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
10+
import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js';
911
import type { IAgentSdkSetupInfo } from '../../../../../../platform/agentHost/common/agentSdkSetup.js';
10-
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';
11-
import type { AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js';
12-
import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, type IChatInputNotification, type IChatInputNotificationAction, type IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js';
12+
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
13+
import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js';
14+
import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js';
15+
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
16+
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';
17+
import { IAgentSdkSetupService, type AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js';
18+
import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js';
19+
import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotificationService, type IChatInputNotification, type IChatInputNotificationAction } from '../../../browser/widget/input/chatInputNotificationService.js';
1320
import { SessionType } from '../../../common/chatSessionsService.js';
21+
import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js';
1422

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

2937
suite('Agent SDK setup banner', () => {
30-
ensureNoDisposablesAreLeakedInTestSuite();
38+
const store = ensureNoDisposablesAreLeakedInTestSuite();
3139

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

89+
test('models add the send-a-message option without changing the Download action', () => {
90+
assert.deepStrictEqual(createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered', true), {
91+
...createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered'),
92+
description: 'Click Download or send a message to download the Claude Agent SDK.',
93+
});
94+
});
95+
8196
test('every noun comes from the agent, so a second agent needs no entry here', () => {
8297
const codex: IAgentSdkSetupInfo = { agent: 'codex', download: 'notDownloaded', signInProviderName: 'ChatGPT' };
8398

@@ -195,6 +210,99 @@ suite('Agent SDK setup banner', () => {
195210
});
196211
});
197212

213+
suite('model availability', () => {
214+
function createFixture(initialSessionTypes: readonly (string | undefined)[] = []) {
215+
const instantiationService = store.add(new TestInstantiationService());
216+
const onDidChangeLanguageModels = store.add(new Emitter<string>());
217+
const models = new Map<string, ILanguageModelChatMetadata>();
218+
const notifications: IChatInputNotification[] = [];
219+
const deletedNotifications: string[] = [];
220+
const reportedStates: AgentSdkSetupState[] = [];
221+
const setModels = (sessionTypes: readonly (string | undefined)[]) => {
222+
models.clear();
223+
for (const [index, targetChatSessionType] of sessionTypes.entries()) {
224+
models.set(`model-${index}`, new class extends mock<ILanguageModelChatMetadata>() {
225+
override readonly targetChatSessionType = targetChatSessionType;
226+
}());
227+
}
228+
onDidChangeLanguageModels.fire('test');
229+
};
230+
231+
instantiationService.stub(IChatInputNotificationService, {
232+
setNotification: notification => notifications.push(notification),
233+
deleteNotification: id => deletedNotifications.push(id),
234+
});
235+
instantiationService.stub(IAgentSdkSetupService, {
236+
setups: [{ agent: 'claude', download: 'notDownloaded' }],
237+
onDidChangeSetups: Event.None,
238+
isDownloadPending: () => false,
239+
reportSetupState: (_agent, state) => reportedStates.push(state),
240+
});
241+
instantiationService.stub(IDefaultAccountService, {
242+
currentDefaultAccount: null,
243+
onDidChangeDefaultAccount: Event.None,
244+
});
245+
instantiationService.stub(ILanguageModelsService, {
246+
onDidChangeLanguageModels: onDidChangeLanguageModels.event,
247+
getLanguageModelIds: () => [...models.keys()],
248+
lookupLanguageModel: id => models.get(id),
249+
});
250+
instantiationService.stub(IConfigurationService, new TestConfigurationService());
251+
instantiationService.stub(IChatEntitlementService, {
252+
entitlement: ChatEntitlement.Pro,
253+
onDidChangeEntitlement: Event.None,
254+
});
255+
instantiationService.stub(IAgentHostService, {
256+
onAgentHostStart: Event.None,
257+
rootState: new class extends mock<IAgentHostService['rootState']>() {
258+
override readonly value = { agents: [{ provider: 'claude', displayName: 'Claude', description: '', models: [] }] };
259+
override readonly onDidChange = Event.None;
260+
}(),
261+
});
262+
263+
setModels(initialSessionTypes);
264+
store.add(instantiationService.createInstance(AgentHostSdkSetupNotificationContribution));
265+
266+
return { notifications, deletedNotifications, reportedStates, setModels };
267+
}
268+
269+
test('explains download-on-use when models are already available', () => {
270+
const fixture = createFixture([SessionType.AgentHostClaude]);
271+
272+
assert.deepStrictEqual(fixture.notifications.map(notification => notification.description), [
273+
'Click Download or send a message to download the Claude Agent SDK.',
274+
]);
275+
});
276+
277+
test('updates the visible offer when models for its agent appear and disappear', () => {
278+
const fixture = createFixture();
279+
fixture.setModels([SessionType.AgentHostCodex, undefined]);
280+
fixture.setModels([SessionType.AgentHostCodex, undefined, SessionType.AgentHostClaude]);
281+
fixture.setModels([SessionType.AgentHostClaude]);
282+
fixture.setModels([]);
283+
284+
assert.deepStrictEqual({
285+
descriptions: fixture.notifications.map(notification => notification.description),
286+
actions: fixture.notifications.map(notification => commandIds(notification.actions)),
287+
deletedNotifications: fixture.deletedNotifications,
288+
reportedStates: fixture.reportedStates,
289+
}, {
290+
descriptions: [
291+
'To use the Claude Agent, we need to download the Claude Agent SDK.',
292+
'Click Download or send a message to download the Claude Agent SDK.',
293+
'To use the Claude Agent, we need to download the Claude Agent SDK.',
294+
],
295+
actions: [
296+
[AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID],
297+
[AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID],
298+
[AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID],
299+
],
300+
deletedNotifications: [],
301+
reportedStates: ['downloadOffered'],
302+
});
303+
});
304+
});
305+
198306
suite('display names', () => {
199307
test('reads each agent name the host published, and skips what it did not', () => {
200308
assert.deepStrictEqual([...getAgentDisplayNames({

0 commit comments

Comments
 (0)