diff --git a/.github/skills/hygiene/SKILL.md b/.github/skills/hygiene/SKILL.md new file mode 100644 index 00000000000..084b76c719c --- /dev/null +++ b/.github/skills/hygiene/SKILL.md @@ -0,0 +1,25 @@ +# Hygiene Checks + +VS Code runs a hygiene check as a git pre-commit hook. Commits will be rejected if hygiene fails. + +## What it checks + +The hygiene linter scans all staged `.ts` files for issues including (but not limited to): + +- **Unicode characters**: Non-ASCII characters (em-dashes, curly quotes, emoji, etc.) are rejected. Use ASCII equivalents in comments and code. +- **Double-quoted strings**: Only use `"double quotes"` for externalized (localized) strings. Use `'single quotes'` everywhere else. +- **Copyright headers**: All files must include the Microsoft copyright header. + +## How it runs + +The git pre-commit hook (via husky) runs `npm run precommit`, which executes: + +```bash +node --experimental-strip-types build/hygiene.ts +``` + +This scans only **staged files** (from `git diff --cached`). To run it manually: + +```bash +npm run precommit +``` diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index ff81e1273fa..7fc854738a2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -9,7 +9,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; -import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -37,7 +36,6 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Cloud: case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: - case AgentSessionProviders.Growth: return type; default: return undefined; @@ -97,11 +95,7 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders } } -export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean { - // Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets - if (contribution?.isReadOnly) { - return false; - } +export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean { switch (provider) { case AgentSessionProviders.Local: case AgentSessionProviders.Background: @@ -127,7 +121,7 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); case AgentSessionProviders.Growth: - return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features."); + return localize('chat.session.providerDescription.growth', "Learn about Copilot features."); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index aad7733c40f..326ebf4e787 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1101,6 +1101,15 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.GrowthNotificationEnabled]: { + type: 'boolean', + description: nls.localize('chat.growthNotification', "Controls whether to show a growth notification in the agent sessions view to encourage new users to try Copilot."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.RestoreLastPanelSession]: { type: 'boolean', description: nls.localize('chat.restoreLastPanelSession', "Controls whether the last session is restored in panel after restart."), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index a5da0877dda..1bb18e32332 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -204,11 +204,6 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint this._register(this.instantiationService.createInstance(ChatSetupController, context, requests))); this.registerSetupAgents(context, controller); + this.registerGrowthSession(chatEntitlementService); this.registerActions(context, requests, controller); this.registerUrlLinkHandler(); this.checkExtensionInstallation(context); @@ -171,6 +176,37 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr this._register(Event.runAndSubscribe(context.onDidChange, () => updateRegistration())); } + private registerGrowthSession(chatEntitlementService: ChatEntitlementService): void { + const growthSessionDisposables = markAsSingleton(new MutableDisposable()); + + const updateGrowthSession = () => { + const experimentEnabled = this.configurationService.getValue(ChatConfiguration.GrowthNotificationEnabled) === true; + // Show for users who don't have the Copilot extension installed yet. + // Additional conditions (e.g., anonymous, entitlement) can be layered here. + const shouldShow = experimentEnabled && !chatEntitlementService.sentiment.installed; + if (shouldShow && !growthSessionDisposables.value) { + const disposables = new DisposableStore(); + const controller = disposables.add(this.instantiationService.createInstance(GrowthSessionController)); + if (!controller.isDismissed) { + disposables.add(registerGrowthSession(this.chatSessionsService, controller)); + // Fully unregister when dismissed to prevent cached session from + // appearing during filtered model updates from other providers. + disposables.add(controller.onDidDismiss(() => { + growthSessionDisposables.clear(); + })); + growthSessionDisposables.value = disposables; + } else { + disposables.dispose(); + } + } else if (!shouldShow) { + growthSessionDisposables.clear(); + } + }; + + this._register(chatEntitlementService.onDidChangeSentiment(() => updateGrowthSession())); + updateGrowthSession(); + } + private registerActions(context: ChatEntitlementContext, requests: ChatEntitlementRequests, controller: Lazy): void { //#region Global Chat Setup Actions diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts new file mode 100644 index 00000000000..1563924cdb3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; +import { IAgentSession } from '../agentSessions/agentSessionsModel.js'; +import { ISessionOpenerParticipant, ISessionOpenOptions, sessionOpenerRegistry } from '../agentSessions/agentSessionsOpener.js'; +import { IChatWidgetService } from '../chat.js'; +import { CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../actions/chatActions.js'; + +/** + * Core-side growth session controller that shows a single "attention needed" + * session item in the agent sessions view for anonymous/new users. + * + * When the user clicks the session, we open the chat panel (which triggers the + * anonymous setup flow). When the user opens chat at all, the badge is cleared. + * + * The session is shown at most once, tracked via a storage flag. + */ +export class GrowthSessionController extends Disposable implements IChatSessionItemController { + + static readonly STORAGE_KEY = 'chat.growthSession.dismissed'; + + private static readonly SESSION_URI = URI.from({ scheme: AgentSessionProviders.Growth, path: '/growth-welcome' }); + + private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); + readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + + private readonly _onDidDismiss = this._register(new Emitter()); + readonly onDidDismiss: Event = this._onDidDismiss.event; + + private readonly _created = Date.now(); + + private _dismissed: boolean; + get isDismissed(): boolean { return this._dismissed; } + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this._dismissed = this.storageService.getBoolean(GrowthSessionController.STORAGE_KEY, StorageScope.APPLICATION, false); + + // Dismiss the growth session when the user opens chat. + // Wait until the workbench is fully restored so we skip widgets + // that were restored from a previous session at startup. + this.lifecycleService.when(LifecyclePhase.Restored).then(() => { + if (this._store.isDisposed || this._dismissed) { + return; + } + this._register(this.chatWidgetService.onDidAddWidget(() => { + this.dismiss(); + })); + }); + } + + get items(): readonly IChatSessionItem[] { + if (this._dismissed) { + return []; + } + + return [{ + resource: GrowthSessionController.SESSION_URI, + label: localize('growthSession.label', "Try Copilot"), + description: localize('growthSession.description', "GitHub Copilot is available. Try it for free."), + status: ChatSessionStatus.NeedsInput, + iconPath: Codicon.lightbulb, + timing: { + created: this._created, + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }, + }]; + } + + async refresh(): Promise { + // Nothing to refresh -- this is a static, local-only session item + } + + private dismiss(): void { + if (this._dismissed) { + return; + } + + this.logService.trace('[GrowthSession] Dismissing growth session'); + this._dismissed = true; + this.storageService.store(GrowthSessionController.STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER); + + // Fire change event first so that listeners (like the model) see empty items + this._onDidChangeChatSessionItems.fire(); + // Then fire dismiss event which triggers unregistration of the controller. + this._onDidDismiss.fire(); + } +} + +/** + * Handles clicks on the growth session item in the agent sessions view. + * Opens a new local chat session with a pre-seeded welcome message. + * The user can then send messages that go through the normal agent. + */ +export class GrowthSessionOpenerParticipant implements ISessionOpenerParticipant { + + async handleOpenSession(accessor: ServicesAccessor, session: IAgentSession, _openOptions?: ISessionOpenOptions): Promise { + if (session.providerType !== AgentSessionProviders.Growth) { + return false; + } + + const commandService = accessor.get(ICommandService); + const opts: IChatViewOpenOptions = { + query: '', + isPartialQuery: true, + previousRequests: [{ + request: localize('growthSession.previousRequest', "Tell me about GitHub Copilot!"), + // allow-any-unicode-next-line + response: localize('growthSession.previousResponse', "Welcome to GitHub Copilot, your AI coding assistant! Here are some things you can try:\n\n- ๐Ÿ› *\"Help me debug this error\"* โ€” paste an error message and get a fix\n- ๐Ÿงช *\"Write tests for my function\"* โ€” select code and ask for unit tests\n- ๐Ÿ’ก *\"Explain this code\"* โ€” highlight something unfamiliar and ask what it does\n- ๐Ÿš€ *\"Scaffold a REST API\"* โ€” describe what you want and let Agent mode build it\n- ๐ŸŽจ *\"Refactor this to be more readable\"* โ€” select messy code and clean it up\n\nType anything below to get started!"), + }], + }; + await commandService.executeCommand(CHAT_OPEN_ACTION_ID, opts); + return true; + } +} + +/** + * Registers the growth session controller and opener participant. + * Returns a disposable that cleans up all registrations. + */ +export function registerGrowthSession(chatSessionsService: IChatSessionsService, growthController: GrowthSessionController): IDisposable { + const disposables = new DisposableStore(); + + // Register as session item controller so it appears in the sessions view + disposables.add(chatSessionsService.registerChatSessionItemController(AgentSessionProviders.Growth, growthController)); + + // Register opener participant so clicking the growth session opens chat + disposables.add(sessionOpenerRegistry.registerParticipant(new GrowthSessionOpenerParticipant())); + + return disposables; +} + +// #region Developer Actions + +registerAction2(class ResetGrowthSessionAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.resetGrowthSession', + title: localize2('resetGrowthSession', "Reset Growth Session Notification"), + category: localize2('developer', "Developer"), + f1: true, + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.remove(GrowthSessionController.STORAGE_KEY, StorageScope.APPLICATION); + } +}); + +// #endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index 9125e269fb3..c9e3d0c4a96 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -132,7 +132,7 @@ export class ChatSuggestNextWidget extends Disposable { return false; } const provider = getAgentSessionProvider(c.type); - return provider !== undefined && getAgentCanContinueIn(provider, c); + return provider !== undefined && getAgentCanContinueIn(provider); }); if (showContinueOn && availableContributions.length > 0) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 37a075a3970..9e5218f2afb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -54,8 +54,7 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return true; // Always show active session type } - const contribution = this.chatSessionsService.getChatSessionContribution(type); - return getAgentCanContinueIn(type, contribution); + return getAgentCanContinueIn(type); } protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index c14a5416cda..c5ab3aae3f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -163,10 +163,6 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const contributions = this.chatSessionsService.getAllChatSessionContributions(); for (const contribution of contributions) { - if (contribution.isReadOnly) { - continue; // Read-only sessions are not interactive and should not appear in session target picker - } - const agentSessionType = getAgentSessionProvider(contribution.type); if (!agentSessionType) { continue; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 839510819e7..9f280a98fb8 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -85,7 +85,6 @@ export interface IChatSessionsExtensionPoint { readonly capabilities?: IChatAgentAttachmentCapabilities; readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; - readonly isReadOnly?: boolean; /** * When set, the chat session will show a filtered mode picker with custom agents * that have a matching `target` property. This enables contributed chat sessions diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 2440311f09b..ab2631dc4bb 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -49,6 +49,7 @@ export enum ChatConfiguration { ExitAfterDelegation = 'chat.exitAfterDelegation', AgentsControlClickBehavior = 'chat.agentsControl.clickBehavior', ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', + GrowthNotificationEnabled = 'chat.growthNotification.enabled', } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index f7fb12e4c80..5c75ce87559 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2100,13 +2100,8 @@ suite('AgentSessions', () => { suite('AgentSessionsViewModel - getAgentCanContinueIn', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('should return false when contribution.isReadOnly is true', () => { - const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true }); - assert.strictEqual(result, false); - }); - - test('should return true for Cloud when contribution is not read-only', () => { - const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false }); + test('should return true for Cloud provider', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud); assert.strictEqual(result, true); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSetup/chatSetupGrowthSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSetup/chatSetupGrowthSession.test.ts new file mode 100644 index 00000000000..d9da6ade3e0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatSetup/chatSetupGrowthSession.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; +import { AgentSessionProviders } from '../../../browser/agentSessions/agentSessions.js'; +import { IAgentSession } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { GrowthSessionController, GrowthSessionOpenerParticipant } from '../../../browser/chatSetup/chatSetupGrowthSession.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { MockChatWidgetService } from '../widget/mockChatWidget.js'; +import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; + +class TestMockChatWidgetService extends MockChatWidgetService { + + private readonly _onDidAddWidget = new Emitter(); + override readonly onDidAddWidget = this._onDidAddWidget.event; + + fireDidAddWidget(): void { + this._onDidAddWidget.fire(undefined!); + } + + dispose(): void { + this._onDidAddWidget.dispose(); + } +} + +suite('GrowthSessionController', () => { + + const disposables = new DisposableStore(); + let instantiationService: TestInstantiationService; + let mockWidgetService: TestMockChatWidgetService; + + setup(() => { + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + mockWidgetService = new TestMockChatWidgetService(); + disposables.add({ dispose: () => mockWidgetService.dispose() }); + const mockLifecycleService = disposables.add(new TestLifecycleService()); + instantiationService.stub(IChatWidgetService, mockWidgetService); + instantiationService.stub(ILifecycleService, mockLifecycleService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return a single NeedsInput session item', () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + + const items = controller.items; + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0].status, ChatSessionStatus.NeedsInput); + assert.strictEqual(items[0].label, 'Try Copilot'); + assert.ok(items[0].resource.scheme === AgentSessionProviders.Growth); + }); + + test('should return empty items after dismiss', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + assert.strictEqual(controller.items.length, 1); + + // Allow the lifecycle.when() promise to resolve and register the listener + await new Promise(r => setTimeout(r, 0)); + + // Fire widget add โ€” should dismiss + mockWidgetService.fireDidAddWidget(); + assert.strictEqual(controller.items.length, 0); + }); + + test('should fire onDidChangeChatSessionItems on dismiss', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + + let fired = false; + disposables.add(controller.onDidChangeChatSessionItems(() => { + fired = true; + })); + + await new Promise(r => setTimeout(r, 0)); + + mockWidgetService.fireDidAddWidget(); + assert.strictEqual(fired, true); + }); + + test('should not fire onDidChangeChatSessionItems twice', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + + let fireCount = 0; + disposables.add(controller.onDidChangeChatSessionItems(() => { + fireCount++; + })); + + await new Promise(r => setTimeout(r, 0)); + + mockWidgetService.fireDidAddWidget(); + mockWidgetService.fireDidAddWidget(); + assert.strictEqual(fireCount, 1); + }); + + test('refresh is a no-op', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + await controller.refresh(); + assert.strictEqual(controller.items.length, 1); + }); +}); + +suite('GrowthSessionOpenerParticipant', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return false for non-Growth sessions', async () => { + const participant = new GrowthSessionOpenerParticipant(); + const session: IAgentSession = { + providerType: AgentSessionProviders.Local, + providerLabel: 'Local', + resource: URI.parse('local://session-1'), + status: ChatSessionStatus.Completed, + label: 'Test Session', + icon: Codicon.vm, + timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }, + isArchived: () => false, + setArchived: () => { }, + isRead: () => true, + setRead: () => { }, + }; + + // The participant checks providerType before touching the accessor, + // so a stub accessor is sufficient for this test path. + const stubAccessor = { get: () => undefined } as unknown as ServicesAccessor; + const result = await participant.handleOpenSession(stubAccessor, session); + assert.strictEqual(result, false); + }); +});