Remove fragile chat notification model switching (#325299)

* Remove fragile model switching

* Cleanup unneeded model lookup
This commit is contained in:
Logan Ramos
2026-07-10 15:24:38 +00:00
committed by GitHub
parent b775d139be
commit 285459cdcc
12 changed files with 282 additions and 84 deletions
@@ -5,25 +5,14 @@
import { Disposable } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { localChatSessionType } from '../common/chatSessionsService.js';
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js';
import { IChatWidgetService } from './chat.js';
import { ChatInputNotificationSeverity, IChatInputNotificationService } from './widget/input/chatInputNotificationService.js';
const PROMO_NOTIFICATION_ID = 'copilot.promoNotification';
const DISMISSED_PROMOS_STORAGE_KEY = 'chat.dismissedPromoIds';
const USE_PROMO_MODEL_COMMAND_ID = 'workbench.action.chat.usePromoModel';
interface IUsePromoModelArgs {
/** Identifier of the model to switch to. */
readonly modelIdentifier: string;
/** Notification to dismiss once the model has been selected. */
readonly notificationId: string;
}
/**
* Watches for models with active promotions and surfaces a chat input
@@ -44,14 +33,6 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
) {
super();
this._register(CommandsRegistry.registerCommand(USE_PROMO_MODEL_COMMAND_ID, (accessor: ServicesAccessor, args: IUsePromoModelArgs) => {
const chatWidgetService = accessor.get(IChatWidgetService);
const widget = chatWidgetService.lastFocusedWidget;
widget?.input.switchModelByIdentifier(args.modelIdentifier);
// Dismissing fires `onDidDismiss`, which persists this promo so it isn't shown again.
this._chatInputNotificationService.dismissNotification(args.notificationId);
}));
this._register(this._languageModelsService.onDidChangeLanguageModels(() => this._update()));
this._register(this._chatInputNotificationService.onDidDismiss(id => {
const promoId = this._shownNotifications.get(id);
@@ -71,28 +52,25 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
const modelIds = this._languageModelsService.getLanguageModelIds();
// A promo can appear in several harnesses at once (e.g. the same model
// offered in the Local, Copilot, and Codex sessions). Each harness has its
// own model copy, so the notification must advertise the model that belongs
// to the harness the chat input is actually in — otherwise the "Use <model>"
// action would switch to a model that isn't valid for that session. Bucket
// the first non-dismissed promo per harness (a model's `targetChatSessionType`,
// offered in the Local, Copilot, and Codex sessions). Bucket the first
// non-dismissed promo per harness (a model's `targetChatSessionType`,
// or the local pool when unset).
const promoByHarness = new Map<string, { readonly promo: NonNullable<ILanguageModelChatMetadata['promo']>; readonly name: string; readonly identifier: string }>();
const promoByHarness = new Map<string, NonNullable<ILanguageModelChatMetadata['promo']>>();
for (const id of modelIds) {
const meta = this._languageModelsService.lookupLanguageModel(id);
if (!meta?.promo || dismissed.has(meta.promo.id)) {
if (!meta || !ILanguageModelChatMetadata.hasPromoDiscount(meta) || dismissed.has(meta.promo.id)) {
continue;
}
const harness = meta.targetChatSessionType ?? localChatSessionType;
if (!promoByHarness.has(harness)) {
promoByHarness.set(harness, { promo: meta.promo, name: meta.name, identifier: id });
promoByHarness.set(harness, meta.promo);
}
}
// Refresh the notification for every harness that has an eligible promo,
// scoping each one to its harness so it only renders in matching sessions.
const desired = new Set<string>();
for (const [harness, { promo, name, identifier }] of promoByHarness) {
for (const [harness, promo] of promoByHarness) {
const notificationId = `${PROMO_NOTIFICATION_ID}.${harness}`;
desired.add(notificationId);
@@ -111,11 +89,7 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
severity: ChatInputNotificationSeverity.Info,
message: promo.message,
description: localize('chat.promo.endsAt', "Ends {0}.", formattedDate),
actions: [{
label: localize('chat.promo.useModel', "Use {0}", name),
commandId: USE_PROMO_MODEL_COMMAND_ID,
commandArgs: [{ modelIdentifier: identifier, notificationId } satisfies IUsePromoModelArgs],
}],
actions: [],
dismissible: true,
autoDismissOnMessage: false,
sessionTypes: [harness],
@@ -95,6 +95,15 @@ export interface IChatInputNotificationService {
* that have {@link IChatInputNotification.autoDismissOnMessage} set.
*/
handleMessageSent(): void;
/**
* Announce a notification that a chat input is about to render to screen
* readers. De-duplicated per notification id across all mounted chat inputs,
* so content shown in several widgets (panel, side bar, …) is only spoken
* once and session-scoped notifications are only announced when a chat input
* in a matching session actually renders them. Passing `undefined` is a no-op.
*/
announceRendered(notification: IChatInputNotification | undefined): void;
}
class ChatInputNotificationService extends Disposable implements IChatInputNotificationService {
@@ -114,11 +123,12 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
readonly onDidDismiss = this._onDidDismiss.event;
/**
* Signature of the last active notification we announced via ARIA, so we
* don't re-announce the same content when the model fires `onDidChange`
* for unrelated reasons or when the same notification is re-pushed.
* Last ARIA-announced signature per notification id. Lets us skip
* re-announcing unchanged content (e.g. a notification re-pushed on every
* quota tick, or the same notification rendered by several mounted chat
* inputs) while still announcing when a notification's content changes.
*/
private _lastAnnouncedSignature: string | undefined;
private readonly _announcedById = new Map<string, string>();
setNotification(notification: IChatInputNotification): void {
this._notifications.set(notification.id, notification);
@@ -131,6 +141,7 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
if (this._notifications.delete(id)) {
this._dismissed.delete(id);
this._insertionOrder.delete(id);
this._announcedById.delete(id);
this._fireDidChange();
}
}
@@ -138,6 +149,8 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
dismissNotification(id: string): void {
if (this._notifications.has(id) && !this._dismissed.has(id)) {
this._dismissed.add(id);
// Forget the announced signature so a later re-show is announced again.
this._announcedById.delete(id);
this._onDidDismiss.fire(id);
this._fireDidChange();
}
@@ -183,37 +196,29 @@ class ChatInputNotificationService extends Disposable implements IChatInputNotif
}
private _fireDidChange(): void {
this._announceActiveIfChanged();
this._onDidChange.fire();
}
/**
* Announce the currently active notification to screen readers, but only
* when its content differs from the last announced one. This prevents
* the same notification from being announced repeatedly when:
* - the same notification is re-pushed by an extension (e.g. on every
* quota change tick),
* - multiple chat widgets are mounted (panel, side bar, etc.) — the
* announcement happens once at the singleton level instead of once
* per widget.
*/
private _announceActiveIfChanged(): void {
const active = this.getActiveNotification();
if (!active) {
this._lastAnnouncedSignature = undefined;
announceRendered(notification: IChatInputNotification | undefined): void {
// Announcements are driven from the chat input's render path (rather than
// eagerly on every change) so that session-scoped notifications are only
// spoken when a chat input in a matching session actually shows them. The
// service still owns the de-dupe state so the same content isn't announced
// once per mounted chat input (panel, side bar, …).
if (!notification) {
return;
}
const rawMessage = typeof active.message === 'string' ? active.message : active.message.value;
const signature = `${active.id}\u0000${rawMessage}\u0000${active.description ?? ''}`;
if (signature === this._lastAnnouncedSignature) {
const rawMessage = typeof notification.message === 'string' ? notification.message : notification.message.value;
const signature = `${notification.id}\u0000${rawMessage}\u0000${notification.description ?? ''}`;
if (this._announcedById.get(notification.id) === signature) {
return;
}
this._lastAnnouncedSignature = signature;
this._announcedById.set(notification.id, signature);
// Strip Markdown syntax so screen readers don't read backticks, link
// targets, etc. verbatim. Done after the signature check so we don't
// pay the parse cost on unrelated `onDidChange` fires.
const message = renderAsPlaintext(active.message);
const text = active.description ? `${message}. ${active.description}` : message;
// targets, etc. verbatim. Done after the de-dupe check so we don't pay
// the parse cost on unrelated re-renders.
const message = renderAsPlaintext(notification.message);
const text = notification.description ? `${message}. ${notification.description}` : message;
status(text);
}
}
@@ -93,6 +93,9 @@ export class ChatInputNotificationWidget extends Disposable {
dom.clearNode(this.domNode);
const notification = this._notificationService.getActiveNotification(n => this._matchesSession(n));
// Announce what this chat input actually renders, so session-scoped
// notifications are only spoken in a matching session (de-duped by the service).
this._notificationService.announceRendered(notification);
if (!notification) {
this.domNode.parentElement?.classList.remove('has-notification');
this._lastShownTelemetryData = undefined;
@@ -34,7 +34,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js';
import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js';
import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelsControlManifest } from '../../../common/languageModels.js';
import { IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelsControlManifest } from '../../../common/languageModels.js';
import { ChatEntitlement, chatRequiresSetup, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js';
import * as semver from '../../../../../../base/common/semver/semver.js';
import { IModelConfigurationAccess, IModelPickerDelegate } from './modelPickerActionItem.js';
@@ -393,7 +393,8 @@ function createModelAction(
// Strip the detail when suppressVendorInDetail is set — the vendor is
// shown either inline (promoted) or in a section header (Other Models).
const detail = suppressVendorInDetail ? undefined : model.metadata.detail;
const promoDetail = model.metadata.promo ? localize('chat.promo.discount', "{0}% discount", model.metadata.promo.discountPercent) : undefined;
const promo = ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined;
const promoDetail = promo ? localize('chat.promo.discount', "{0}% discount", promo.discountPercent) : undefined;
const textParts = [detail, promoDetail, pricingForDescription].filter(Boolean);
const textDescription = textParts.length > 0 ? textParts.join(' · ') : undefined;
@@ -651,12 +652,12 @@ export function buildModelPickerItems(
items.push(createModelItem(autoAction, autoModel, openerService, undefined, isUBB, autoAriaDesc));
}
// --- 1b. Promo models (boosted next to Auto) ---
// --- 1b. Discounted promo models (boosted next to Auto) ---
for (const model of models) {
if (placed.has(model.identifier) || placed.has(model.metadata.id)) {
continue;
}
if (model.metadata.promo) {
if (ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) {
markPlaced(model.identifier, model.metadata.id);
const { action: promoAction, ariaDescription: promoAriaDesc } = createModelAction(model, selectedModelId, onSelect);
items.push(createModelItem(promoAction, model, openerService, undefined, isUBB, promoAriaDesc));
@@ -747,6 +748,15 @@ export function buildModelPickerItems(
tryPlaceModel(id);
}
// Non-discount promos are featured without promotional presentation.
if (showFeatured) {
for (const model of models) {
if (model.metadata.promo && !ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) {
tryPlaceModel(model.identifier);
}
}
}
// Featured models from control manifest
if (showFeatured) {
for (const [entryId, entry] of Object.entries(controlModels)) {
@@ -1821,6 +1831,7 @@ const SUPPORTED_CONFIG_GROUPS: readonly string[] = ['navigation', 'tokens'];
export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, isUBB: boolean | undefined, onConfigure: ((group: string) => void) | undefined, openerService: IOpenerService): { element: HTMLElement; disposable: DisposableStore } | undefined {
const isAuto = isAutoModel(model);
const promo = !isAuto && ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined;
const container = dom.$('.chat-model-hover');
const disposables = new DisposableStore();
@@ -1828,7 +1839,7 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif
const titleRow = dom.$('.chat-model-hover-title-row');
titleRow.appendChild(dom.$('.chat-model-hover-name', undefined, model.metadata.name));
const tags = dom.$('.chat-model-hover-title-tags');
const categoryLabel = !isAuto && !model.metadata.promo ? getCategoryLabel(model.metadata.category) : undefined;
const categoryLabel = !isAuto && !promo ? getCategoryLabel(model.metadata.category) : undefined;
if (categoryLabel) {
tags.appendChild(dom.$('span.chat-model-hover-category', undefined, categoryLabel));
}
@@ -1842,8 +1853,8 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif
tags.appendChild(badge);
}
// When a model carries a promo discount, show a discount pill alongside the price category.
if (!isAuto && model.metadata.promo) {
const discountLabel = localize('chat.promo.discountBadge', "{0}% discount", model.metadata.promo.discountPercent);
if (promo) {
const discountLabel = localize('chat.promo.discountBadge', "{0}% discount", promo.discountPercent);
tags.appendChild(dom.$('span.chat-model-hover-price-badge', undefined, discountLabel));
}
if (tags.childElementCount > 0) {
@@ -1867,12 +1878,12 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif
}
// --- Promo info ---
if (!isAuto && model.metadata.promo) {
if (promo) {
const promoContainer = dom.$('.chat-model-hover-promo-text');
promoContainer.appendChild(renderIcon(Codicon.info));
const endsAtDate = new Date(model.metadata.promo.endsAt);
const endsAtDate = new Date(promo.endsAt);
const formattedDate = endsAtDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
const promoMessage = model.metadata.promo.message + ' ' + localize('chat.promo.endsAt', "Ends {0}.", formattedDate);
const promoMessage = promo.message + ' ' + localize('chat.promo.endsAt', "Ends {0}.", formattedDate);
const promoMd = new MarkdownString(promoMessage, { isTrusted: false, supportThemeIcons: true });
const rendered = disposables.add(renderMarkdown(promoMd, {
actionHandler: (link: string) => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); },
@@ -313,8 +313,8 @@ export interface ILanguageModelChatMetadata {
*/
readonly warningText?: IStringDictionary<string>;
/**
* Optional promotional information for this model.
* When present, indicates the model is experiencing a promotional discount.
* Optional promotional information for this model. Positive discounts surface
* promotional UI; non-positive discounts only feature the model in the picker.
*/
readonly promo?: {
readonly id: string;
@@ -341,6 +341,10 @@ export namespace ILanguageModelChatMetadata {
return name === asQualifiedName(metadata);
}
export function hasPromoDiscount(metadata: ILanguageModelChatMetadata): metadata is ILanguageModelChatMetadata & { readonly promo: NonNullable<ILanguageModelChatMetadata['promo']> } {
return !!metadata.promo && metadata.promo.discountPercent > 0;
}
/**
* Documentation link explaining how Auto model selection works.
* NOTE: Also defined in extensions/copilot/src/extension/conversation/common/languageModelAccess.ts — keep in sync.
@@ -62,6 +62,7 @@ class FakeNotificationService implements IChatInputNotificationService {
dismissNotification(_id: string): void { /* */ }
getActiveNotification(): IChatInputNotification | undefined { return undefined; }
handleMessageSent(): void { /* */ }
announceRendered(): void { /* */ }
}
/**
@@ -62,6 +62,7 @@ function createMockNotificationService(disposables: Pick<DisposableStore, 'add'>
return active;
},
handleMessageSent() { },
announceRendered() { },
};
return {
@@ -125,8 +126,30 @@ suite('ChatPromoNotificationContribution', () => {
const notification = notifService.getNotification();
assert.ok(notification, 'Expected a notification to be shown');
assert.ok(notification.message.toString().includes('20% off'));
assert.strictEqual(notification.actions.length, 1);
assert.ok(notification.actions[0].label.includes('GPT-5.5'));
assert.strictEqual(notification.actions.length, 0);
});
test('does not show notification for non-positive promo discounts', () => {
const notifService = createMockNotificationService(disposables);
const { service: lmService } = createMockLanguageModelsService([
{
identifier: 'copilot:zero-discount',
metadata: { name: 'Zero Discount', id: 'zero-discount', promo: { id: 'promo-zero', discountPercent: 0, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
},
{
identifier: 'copilot:negative-discount',
metadata: { name: 'Negative Discount', id: 'negative-discount', promo: { id: 'promo-negative', discountPercent: -10, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
},
], disposables);
const storageService = disposables.add(new InMemoryStorageService());
disposables.add(new ChatPromoNotificationContribution(
lmService,
notifService.service,
storageService,
));
assert.strictEqual(notifService.getNotification(), undefined);
});
test('does not show notification for already-dismissed promo', () => {
@@ -280,22 +303,21 @@ suite('ChatPromoNotificationContribution', () => {
// One notification per harness.
assert.strictEqual(notifService.getAllNotifications().length, 3);
// Each session only sees the promo for the model that belongs to it, and
// the "Use <model>" action switches to that harness's model.
// Each session only sees the promo for the model that belongs to it.
const local = notifService.getNotificationForSession('local');
assert.ok(local, 'Expected a local promo');
assert.ok(local.message.toString().includes('Local promo'));
assert.deepStrictEqual(local.actions[0].commandArgs, [{ modelIdentifier: 'local:gpt-5.5', notificationId: local.id }]);
assert.strictEqual(local.actions.length, 0);
const copilot = notifService.getNotificationForSession('copilotcli');
assert.ok(copilot, 'Expected a Copilot promo');
assert.ok(copilot.message.toString().includes('Copilot promo'));
assert.deepStrictEqual(copilot.actions[0].commandArgs, [{ modelIdentifier: 'copilot:claude', notificationId: copilot.id }]);
assert.strictEqual(copilot.actions.length, 0);
const codex = notifService.getNotificationForSession('openai-codex');
assert.ok(codex, 'Expected a Codex promo');
assert.ok(codex.message.toString().includes('Codex promo'));
assert.deepStrictEqual(codex.actions[0].commandArgs, [{ modelIdentifier: 'codex:o4', notificationId: codex.id }]);
assert.strictEqual(codex.actions.length, 0);
});
test('does not leak a harness promo into a different session type', () => {
@@ -133,6 +133,7 @@ function createMockNotificationService() {
return !filter || filter(lastNotification) ? lastNotification : undefined;
},
handleMessageSent() { },
announceRendered() { },
};
return {
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { Event } from '../../../../../../../base/common/event.js';
import { Emitter, Event } from '../../../../../../../base/common/event.js';
import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { ICommandEvent, ICommandService } from '../../../../../../../platform/commands/common/commands.js';
@@ -14,7 +14,7 @@ import { ServiceCollection } from '../../../../../../../platform/instantiation/c
import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js';
import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js';
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { ChatInputNotificationSeverity, IChatInputNotificationService } from '../../../../browser/widget/input/chatInputNotificationService.js';
import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../../../browser/widget/input/chatInputNotificationService.js';
import { ChatInputNotificationWidget } from '../../../../browser/widget/input/chatInputNotificationWidget.js';
import { localChatSessionType, SessionType } from '../../../../common/chatSessionsService.js';
@@ -24,7 +24,10 @@ class TestCommandService implements ICommandService {
readonly onWillExecuteCommand: Event<ICommandEvent> = Event.None;
readonly onDidExecuteCommand: Event<ICommandEvent> = Event.None;
async executeCommand(): Promise<undefined> {
readonly executed: { readonly id: string; readonly args: readonly unknown[] }[] = [];
async executeCommand(id: string, ...args: unknown[]): Promise<undefined> {
this.executed.push({ id, args });
return undefined;
}
}
@@ -78,4 +81,126 @@ suite('ChatInputNotificationWidget', () => {
widget.rerender();
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification')?.textContent, 'Local only');
});
/**
* A notification service mock that records the notifications forwarded to
* {@link IChatInputNotificationService.announceRendered} and applies the
* `getActiveNotification` filter, so tests can observe exactly what a chat
* input would render and announce for its session.
*/
function createRecordingNotificationService() {
const notifications = new Map<string, IChatInputNotification>();
const announced: (IChatInputNotification | undefined)[] = [];
const onDidChange = store.add(new Emitter<void>());
const service: IChatInputNotificationService = {
_serviceBrand: undefined,
onDidChange: onDidChange.event,
onDidDismiss: Event.None,
setNotification(notification) { notifications.set(notification.id, notification); onDidChange.fire(); },
deleteNotification(id) { if (notifications.delete(id)) { onDidChange.fire(); } },
dismissNotification() { },
getActiveNotification(filter) {
let active: IChatInputNotification | undefined;
for (const notification of notifications.values()) {
if (filter && !filter(notification)) {
continue;
}
active = notification;
}
return active;
},
handleMessageSent() { },
announceRendered(notification) { announced.push(notification); },
};
return { service, announced, set: (notification: IChatInputNotification) => service.setNotification(notification) };
}
test('action commands execute with provided args', async () => {
const commandService = new TestCommandService();
const notificationService = createRecordingNotificationService();
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
instantiationService.stub(IChatInputNotificationService, notificationService.service);
instantiationService.stub(ICommandService, commandService);
instantiationService.stub(ITelemetryService, NullTelemetryService);
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, () => localChatSessionType));
notificationService.set({
id: 'promo',
severity: ChatInputNotificationSeverity.Info,
message: 'Promo',
description: undefined,
actions: [{ label: 'Use', commandId: 'test.usePromo', commandArgs: [{ modelIdentifier: 'm' }] }],
dismissible: true,
autoDismissOnMessage: false,
});
const button = widget.domNode.querySelector<HTMLElement>('.chat-input-notification-action-button');
assert.ok(button);
button.click();
await Promise.resolve();
assert.deepStrictEqual(commandService.executed, [{ id: 'test.usePromo', args: [{ modelIdentifier: 'm' }] }]);
});
test('actions without explicit commandArgs are executed with empty args', async () => {
const commandService = new TestCommandService();
const notificationService = createRecordingNotificationService();
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
instantiationService.stub(IChatInputNotificationService, notificationService.service);
instantiationService.stub(ICommandService, commandService);
instantiationService.stub(ITelemetryService, NullTelemetryService);
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, () => localChatSessionType));
notificationService.set({
id: 'info',
severity: ChatInputNotificationSeverity.Info,
message: 'Info',
description: undefined,
actions: [{ label: 'Upgrade', commandId: 'test.upgrade' }],
dismissible: true,
autoDismissOnMessage: false,
});
const button = widget.domNode.querySelector<HTMLElement>('.chat-input-notification-action-button');
assert.ok(button);
button.click();
await Promise.resolve();
assert.deepStrictEqual(commandService.executed, [{ id: 'test.upgrade', args: [] }]);
});
test('announces only the notification rendered in the current session', () => {
let currentSessionType = localChatSessionType;
const notificationService = createRecordingNotificationService();
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
instantiationService.stub(IChatInputNotificationService, notificationService.service);
instantiationService.stub(ICommandService, new TestCommandService());
instantiationService.stub(ITelemetryService, NullTelemetryService);
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, () => currentSessionType));
const lastAnnounced = () => notificationService.announced[notificationService.announced.length - 1];
// A promo scoped to the Copilot harness must not be announced while the
// input is in the local session.
notificationService.set({
id: 'copilot-promo',
severity: ChatInputNotificationSeverity.Info,
message: 'Copilot promo',
description: undefined,
actions: [],
dismissible: true,
autoDismissOnMessage: false,
sessionTypes: [SessionType.AgentHostCopilot],
});
assert.strictEqual(lastAnnounced(), undefined, 'nothing should be announced in a non-matching session');
currentSessionType = SessionType.AgentHostCopilot;
widget.rerender();
assert.strictEqual(lastAnnounced()?.id, 'copilot-promo', 'the promo should be announced once its session is active');
});
});
@@ -10,8 +10,9 @@ import { IStringDictionary } from '../../../../../../../base/common/collections.
import { MarkdownString } from '../../../../../../../base/common/htmlContent.js';
import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js';
import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js';
import { NullOpenerService } from '../../../../../../../platform/opener/test/common/nullOpenerService.js';
import { StateType } from '../../../../../../../platform/update/common/update.js';
import { buildModelPickerItems, getControlModelsForEntitlement, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js';
import { buildModelPickerItems, getControlModelsForEntitlement, getModelHoverContent, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js';
import { getModelProviderIcon } from '../../../../browser/widget/input/modelProviderIcons.js';
import { filterModelsForSession } from '../../../../browser/widget/input/chatModelSelectionLogic.js';
import { ChatAgentLocation, ChatModeKind } from '../../../../common/constants.js';
@@ -217,7 +218,7 @@ function createControlManifest(): IModelsControlManifest {
suite('buildModelPickerItems', () => {
ensureNoDisposablesAreLeakedInTestSuite();
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
test('accessibility provider uses radio semantics for model items', () => {
const provider = getModelPickerAccessibilityProvider();
@@ -754,6 +755,55 @@ suite('buildModelPickerItems', () => {
assert.strictEqual(allGemini.length, 1, 'Promo model should appear exactly once');
});
test('non-positive promo models are featured without discount details', () => {
const auto = createAutoModel();
const zeroDiscountModel = createModel('zero-discount', 'Zero Discount');
zeroDiscountModel.metadata = { ...zeroDiscountModel.metadata, promo: { id: 'test-promo-zero', discountPercent: 0, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } } as ILanguageModelChatMetadata;
const negativeDiscountModel = createModel('negative-discount', 'Negative Discount');
negativeDiscountModel.metadata = { ...negativeDiscountModel.metadata, promo: { id: 'test-promo-negative', discountPercent: -10, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } } as ILanguageModelChatMetadata;
const manifestFeaturedModel = createModel('manifest-featured', 'Manifest Featured');
const items = callBuild([auto, zeroDiscountModel, negativeDiscountModel, manifestFeaturedModel], {
controlModels: {
'manifest-featured': { label: 'Manifest Featured', featured: true, exists: true },
},
});
const labels = new Set(['Auto', 'Zero Discount', 'Negative Discount', 'Manifest Featured']);
const featuredItems = getActionItems(items).filter(item => labels.has(item.label!));
assert.deepStrictEqual(featuredItems.map(item => ({ label: item.label, description: item.description })), [
{ label: 'Auto', description: undefined },
{ label: 'Manifest Featured', description: undefined },
{ label: 'Negative Discount', description: undefined },
{ label: 'Zero Discount', description: undefined },
]);
});
test('non-positive promo models have no promo hover presentation', () => {
const results = [0, -10].map(discountPercent => {
const model = createModel(`discount-${discountPercent}`, `Discount ${discountPercent}`);
model.metadata = {
...model.metadata,
category: 'powerful',
priceCategory: 'high',
promo: { id: `test-promo-${discountPercent}`, discountPercent, endsAt: '2026-07-20T23:59:59Z', message: 'Do not render this text' },
} as ILanguageModelChatMetadata;
const hover = getModelHoverContent(model, false, undefined, NullOpenerService);
assert.ok(hover);
disposables.add(hover.disposable);
return {
discountPercent,
category: hover.element.querySelector('.chat-model-hover-category')?.textContent,
badges: Array.from(hover.element.querySelectorAll('.chat-model-hover-price-badge'), element => element.textContent),
promoText: hover.element.querySelector('.chat-model-hover-promo-text')?.textContent,
};
});
assert.deepStrictEqual(results, [
{ discountPercent: 0, category: 'Powerful', badges: ['High cost'], promoText: undefined },
{ discountPercent: -10, category: 'Powerful', badges: ['High cost'], promoText: undefined },
]);
});
test('Other Models grouped by vendor with separator headers', () => {
const auto = createAutoModel();
const modelA = createModel('zebra', 'Zebra', 'copilot');
@@ -238,6 +238,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I
reg.defineInstance(IChatInputNotificationService, new class extends mock<IChatInputNotificationService>() {
override readonly onDidChange = Event.None;
override getActiveNotification() { return undefined; }
override announceRendered() { }
}());
reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); }());
// Agent-host chat widgets (e.g. the turn changes summary fixtures) create the
@@ -322,6 +322,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo
reg.defineInstance(IChatInputNotificationService, new class extends mock<IChatInputNotificationService>() {
override readonly onDidChange = Event.None;
override getActiveNotification() { return undefined; }
override announceRendered() { }
}());
reg.defineInstance(ICustomizationHarnessService, new class extends mock<ICustomizationHarnessService>() {
override readonly onDidChangeSlashCommands = Event.None;