From cf6e184fbe50f168fc5cd44d243e83bb59be8fd2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 00:12:35 -0600 Subject: [PATCH] tighten tip `when` conditions, spilt out `/create` commands (#297555) --- .../contrib/chat/browser/chatTipService.ts | 185 ++++++++++++------ .../createSlashCommandsUsageTracker.ts | 9 +- .../chat/common/chatService/chatService.ts | 2 +- .../common/chatService/chatServiceImpl.ts | 4 +- .../chat/test/browser/chatTipService.test.ts | 142 +++++++++----- .../common/chatService/mockChatService.ts | 2 +- 6 files changed, 223 insertions(+), 121 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 9c78ef90cd9..1c82947166d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -25,6 +25,7 @@ import { IChatService } from '../common/chatService/chatService.js'; import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; type ChatTipEvent = { tipId: string; @@ -40,6 +41,12 @@ type ChatTipClassification = { comment: 'Tracks user interactions with chat tips to understand which tips resonate and which are dismissed.'; }; +const ATTACH_FILES_REFERENCE_TRACKING_COMMAND = 'chat.tips.attachFiles.referenceUsed'; +const CREATE_INSTRUCTION_TRACKING_COMMAND = 'chat.tips.createInstruction.commandUsed'; +const CREATE_PROMPT_TRACKING_COMMAND = 'chat.tips.createPrompt.commandUsed'; +const CREATE_AGENT_TRACKING_COMMAND = 'chat.tips.createAgent.commandUsed'; +const CREATE_SKILL_TRACKING_COMMAND = 'chat.tips.createSkill.commandUsed'; + export const IChatTipService = createDecorator('chatTipService'); export interface IChatTip { @@ -182,26 +189,55 @@ const TIP_CATALOG: ITipDefinition[] = [ onlyWhenModelIds: ['gpt-4.1'], }, { - id: 'tip.createSlashCommands', + id: 'tip.createInstruction', message: localize( - 'tip.createSlashCommands', - "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction), [/create-prompt](command:workbench.action.chat.generatePrompt), [/create-agent](command:workbench.action.chat.generateAgent), or [/create-skill](command:workbench.action.chat.generateSkill) to generate reusable agent customization files." + 'tip.createInstruction', + "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction) to generate an on-demand instruction file with the agent." ), - when: ContextKeyExpr.and( - ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - ChatContextKeys.hasUsedCreateSlashCommands.negate(), - ), - enabledCommands: [ - 'workbench.action.chat.generateInstruction', - 'workbench.action.chat.generatePrompt', - 'workbench.action.chat.generateAgent', - 'workbench.action.chat.generateSkill', - ], + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generateInstruction'], excludeWhenCommandsExecuted: [ 'workbench.action.chat.generateInstruction', + CREATE_INSTRUCTION_TRACKING_COMMAND, + ], + }, + { + id: 'tip.createPrompt', + message: localize( + 'tip.createPrompt', + "Tip: Use [/create-prompt](command:workbench.action.chat.generatePrompt) to generate a reusable prompt file with the agent." + ), + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generatePrompt'], + excludeWhenCommandsExecuted: [ 'workbench.action.chat.generatePrompt', + CREATE_PROMPT_TRACKING_COMMAND, + ], + }, + { + id: 'tip.createAgent', + message: localize( + 'tip.createAgent', + "Tip: Use [/create-agent](command:workbench.action.chat.generateAgent) to scaffold a custom agent for your workflow." + ), + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generateAgent'], + excludeWhenCommandsExecuted: [ 'workbench.action.chat.generateAgent', + CREATE_AGENT_TRACKING_COMMAND, + ], + }, + { + id: 'tip.createSkill', + message: localize( + 'tip.createSkill', + "Tip: Use [/create-skill](command:workbench.action.chat.generateSkill) to create a skill the agent can load when relevant." + ), + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generateSkill'], + excludeWhenCommandsExecuted: [ 'workbench.action.chat.generateSkill', + CREATE_SKILL_TRACKING_COMMAND, ], }, { @@ -222,7 +258,7 @@ const TIP_CATALOG: ITipDefinition[] = [ { id: 'tip.attachFiles', message: localize('tip.attachFiles', "Tip: Reference files or folders with # to give the agent more context about the task."), - excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection'], + excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection', ATTACH_FILES_REFERENCE_TRACKING_COMMAND], }, { id: 'tip.codeActions', @@ -241,29 +277,6 @@ const TIP_CATALOG: ITipDefinition[] = [ ), excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], }, - { - id: 'tip.customInstructions', - message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) apply coding conventions across all agent sessions."), - enabledCommands: ['workbench.action.chat.generateInstructions'], - excludeWhenCommandsExecuted: ['workbench.action.chat.generateInstructions'], - excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }, - { - id: 'tip.customAgent', - message: localize('tip.customAgent', "Tip: [Create a custom agent](command:workbench.command.new.agent) to define reusable personas with tailored instructions and tools for your workflow."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - enabledCommands: ['workbench.command.new.agent'], - excludeWhenCommandsExecuted: ['workbench.command.new.agent'], - excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, - }, - { - id: 'tip.skill', - message: localize('tip.skill', "Tip: [Create a skill](command:workbench.command.new.skill) to teach the agent specialized workflows, loaded only when relevant."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - enabledCommands: ['workbench.command.new.skill'], - excludeWhenCommandsExecuted: ['workbench.command.new.skill'], - excludeWhenPromptFilesExist: { promptType: PromptsType.skill, excludeUntilChecked: true }, - }, { id: 'tip.messageQueueing', message: localize('tip.messageQueueing', "Tip: Steer the agent mid-task by sending follow-up messages. They queue and apply in order."), @@ -324,7 +337,6 @@ export class TipEligibilityTracker extends Disposable { private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands'; private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes'; private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools'; - private static readonly _INSTRUCTION_FILES_EVER_DETECTED_KEY = 'chat.tips.instructionFilesEverDetected'; private readonly _executedCommands: Set; private readonly _usedModes: Set; @@ -349,7 +361,6 @@ export class TipEligibilityTracker extends Disposable { /** Generation counter per tip ID to discard stale async file-check results. */ private readonly _fileCheckGeneration = new Map(); private readonly _fileChecksInFlight = new Map>(); - private _instructionFilesEverDetected: boolean; constructor( tips: readonly ITipDefinition[], @@ -372,8 +383,6 @@ export class TipEligibilityTracker extends Disposable { const storedTools = this._readApplicationWithProfileFallback(TipEligibilityTracker._TOOLS_STORAGE_KEY); this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); - this._instructionFilesEverDetected = this._storageService.getBoolean(TipEligibilityTracker._INSTRUCTION_FILES_EVER_DETECTED_KEY, StorageScope.APPLICATION, false); - // --- Derive what still needs tracking ---------------------------------- this._pendingCommands = new Set(); @@ -407,15 +416,7 @@ export class TipEligibilityTracker extends Disposable { if (this._pendingCommands.size > 0) { this._commandListener.value = commandService.onDidExecuteCommand(e => { - if (this._pendingCommands.has(e.commandId)) { - this._executedCommands.add(e.commandId); - this._persistSet(TipEligibilityTracker._COMMANDS_STORAGE_KEY, this._executedCommands); - this._pendingCommands.delete(e.commandId); - - if (this._pendingCommands.size === 0) { - this._commandListener.clear(); - } - } + this.recordCommandExecuted(e.commandId); }); } @@ -441,11 +442,6 @@ export class TipEligibilityTracker extends Disposable { this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); for (const tip of this._tipsWithFileExclusions) { - if (this._instructionFilesEverDetected && tip.id === 'tip.customInstructions') { - this._excludedByFiles.add(tip.id); - continue; - } - if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { this._excludedByFiles.add(tip.id); } @@ -462,6 +458,20 @@ export class TipEligibilityTracker extends Disposable { })); } + recordCommandExecuted(commandId: string): void { + if (!this._pendingCommands.has(commandId)) { + return; + } + + this._executedCommands.add(commandId); + this._persistSet(TipEligibilityTracker._COMMANDS_STORAGE_KEY, this._executedCommands); + this._pendingCommands.delete(commandId); + + if (this._pendingCommands.size === 0) { + this._commandListener.clear(); + } + } + /** * Records the current chat mode (kind + name) so future tip eligibility * checks can exclude mode-related tips. No-ops once all tracked modes @@ -531,11 +541,6 @@ export class TipEligibilityTracker extends Disposable { */ refreshPromptFileExclusions(): void { for (const tip of this._tipsWithFileExclusions) { - if (this._instructionFilesEverDetected && tip.id === 'tip.customInstructions') { - this._excludedByFiles.add(tip.id); - continue; - } - if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { this._excludedByFiles.add(tip.id); } @@ -583,11 +588,6 @@ export class TipEligibilityTracker extends Disposable { : false; const hasPromptFilesOrAgentFile = hasPromptFiles || hasAgentFile; - if (tip.id === 'tip.customInstructions' && hasPromptFilesOrAgentFile) { - this._instructionFilesEverDetected = true; - this._storageService.store(TipEligibilityTracker._INSTRUCTION_FILES_EVER_DETECTED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - if (hasPromptFilesOrAgentFile) { this._excludedByFiles.add(tip.id); } else { @@ -684,6 +684,22 @@ export class ChatTipService extends Disposable implements IChatTipService { } })); + this._register(this._chatService.onDidSubmitRequest(e => { + const message = e.message ?? this._chatService.getSession(e.chatSessionResource)?.lastRequest?.message; + if (!message) { + return; + } + + if (this._hasFileOrFolderReference(message)) { + this._tracker.recordCommandExecuted(ATTACH_FILES_REFERENCE_TRACKING_COMMAND); + } + + const createCommandTrackingId = this._getCreateSlashCommandTrackingId(message); + if (createCommandTrackingId) { + this._tracker.recordCommandExecuted(createCommandTrackingId); + } + })); + // Track whether yolo mode was ever enabled this._yoloModeEverEnabled = this._storageService.getBoolean(ChatTipService._YOLO_EVER_ENABLED_KEY, StorageScope.APPLICATION, false); if (!this._yoloModeEverEnabled && this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { @@ -718,6 +734,45 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + private _hasFileOrFolderReference(message: IParsedChatRequest): boolean { + return message.parts.some(part => { + if (part.kind !== ChatRequestDynamicVariablePart.Kind) { + return false; + } + + const dynamicPart = part as ChatRequestDynamicVariablePart; + return dynamicPart.isFile === true || dynamicPart.isDirectory === true; + }); + } + + private _getCreateSlashCommandTrackingId(message: IParsedChatRequest): string | undefined { + for (const part of message.parts) { + if (part.kind === ChatRequestSlashCommandPart.Kind) { + const slashCommand = (part as ChatRequestSlashCommandPart).slashCommand.command; + return this._toCreateSlashCommandTrackingId(slashCommand); + } + } + + const trimmed = message.text.trimStart(); + const match = /^\/(create-(?:instruction|prompt|agent|skill))(?:\s|$)/.exec(trimmed); + return match ? this._toCreateSlashCommandTrackingId(match[1]) : undefined; + } + + private _toCreateSlashCommandTrackingId(command: string): string | undefined { + switch (command) { + case 'create-instruction': + return CREATE_INSTRUCTION_TRACKING_COMMAND; + case 'create-prompt': + return CREATE_PROMPT_TRACKING_COMMAND; + case 'create-agent': + return CREATE_AGENT_TRACKING_COMMAND; + case 'create-skill': + return CREATE_SKILL_TRACKING_COMMAND; + default: + return undefined; + } + } + resetSession(): void { this._shownTip = undefined; this._tipRequestId = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts index 0ad8f07e43c..c22041aa980 100644 --- a/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts +++ b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts @@ -21,13 +21,12 @@ export class CreateSlashCommandsUsageTracker extends Disposable { super(); this._register(this._chatService.onDidSubmitRequest(e => { - const model = this._chatService.getSession(e.chatSessionResource); - const lastRequest = model?.lastRequest; - if (!lastRequest) { + const message = e.message ?? this._chatService.getSession(e.chatSessionResource)?.lastRequest?.message; + if (!message) { return; } - for (const part of lastRequest.message.parts) { + for (const part of message.parts) { if (part.kind === ChatRequestSlashCommandPart.Kind) { const slash = part as ChatRequestSlashCommandPart; if (CreateSlashCommandsUsageTracker._isCreateSlashCommand(slash.slashCommand.command)) { @@ -38,7 +37,7 @@ export class CreateSlashCommandsUsageTracker extends Disposable { } // Fallback when parsing doesn't produce a slash command part. - const trimmed = lastRequest.message.text.trimStart(); + const trimmed = message.text.trimStart(); const match = /^\/(create-(?:instruction|prompt|agent|skill))(?:\s|$)/.exec(trimmed); if (match && CreateSlashCommandsUsageTracker._isCreateSlashCommand(match[1])) { this._markUsed(); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index ecf9c393b6b..70a63b90a1e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1345,7 +1345,7 @@ export interface IChatService { _serviceBrand: undefined; transferredSessionResource: URI | undefined; - readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }>; + readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>; readonly onDidCreateModel: Event; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0743b82d5f2..42b409dc8b3 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -107,7 +107,7 @@ export class ChatService extends Disposable implements IChatService { return this._transferredSessionResource; } - private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>()); + private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); public readonly onDidSubmitRequest = this._onDidSubmitRequest.event; public get onDidCreateModel() { return this._sessionModels.onDidCreateModel; } @@ -1228,7 +1228,7 @@ export class ChatService extends Disposable implements IChatService { if (options?.userSelectedModelId) { this.languageModelsService.addToRecentlyUsedList(options.userSelectedModelId); } - this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource }); + this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource, message: parsedRequest }); return { responseCreatedPromise: responseCreated.p, responseCompletePromise: rawResponsePromise, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 5af2338c702..8be5fe9be00 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -28,7 +28,7 @@ import { TestChatEntitlementService } from '../../../../test/common/workbenchTes import { IChatService } from '../../common/chatService/chatService.js'; import { MockChatService } from '../common/chatService/mockChatService.js'; import { CreateSlashCommandsUsageTracker } from '../../browser/createSlashCommandsUsageTracker.js'; -import { ChatRequestSlashCommandPart } from '../../common/requestParser/chatParserTypes.js'; +import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -117,6 +117,62 @@ suite('ChatTipService', () => { assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); + test('records # file reference usage for attach files tip eligibility', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + createService(); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-attach-file'), + message: { + text: 'what does #file:README.md say', + parts: [new ChatRequestDynamicVariablePart( + new OffsetRange(10, 26), + new Range(1, 11, 1, 27), + '#file:README.md', + 'file', + undefined, + URI.file('/workspace/README.md'), + undefined, + undefined, + true, + false, + )], + }, + }); + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes('chat.tips.attachFiles.referenceUsed')); + }); + + test('records only matching create tip usage for submitted create command', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + createService(); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-create-prompt'), + message: { + text: '/create-prompt scaffold a reusable prompt', + parts: [], + }, + }); + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes('chat.tips.createPrompt.commandUsed')); + assert.ok(!executedCommands.includes('chat.tips.createInstruction.commandUsed')); + assert.ok(!executedCommands.includes('chat.tips.createAgent.commandUsed')); + assert.ok(!executedCommands.includes('chat.tips.createSkill.commandUsed')); + }); + test('returns Auto switch tip when current model is gpt-4.1', () => { const service = createService(); contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); @@ -846,53 +902,55 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); - test('shows tip.createSlashCommands when context key is false', () => { + test('shows all create slash command tips in local chat sessions', () => { const service = createService(); - contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); - // Dismiss tips until we find createSlashCommands or run out - let found = false; + const expectedCreateTips = new Set(['tip.createInstruction', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); + const seenCreateTips = new Set(); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); if (!tip) { break; } - if (tip.id === 'tip.createSlashCommands') { - found = true; - break; + if (expectedCreateTips.has(tip.id)) { + seenCreateTips.add(tip.id); + if (seenCreateTips.size === expectedCreateTips.size) { + break; + } } service.dismissTip(); } - assert.ok(found, 'Should eventually show tip.createSlashCommands when context key is false'); + assert.deepStrictEqual([...seenCreateTips].sort(), [...expectedCreateTips].sort()); }); - test('does not show tip.createSlashCommands in non-local chat sessions', () => { + test('does not show create slash command tips in non-local chat sessions', () => { const service = createService(); - contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); contextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'cloud'); + const createTipIds = new Set(['tip.createInstruction', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); if (!tip) { break; } - assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands in non-local sessions'); + assert.ok(!createTipIds.has(tip.id), 'Should not show create slash command tips in non-local sessions'); service.dismissTip(); } }); - test('does not show tip.createSlashCommands when context key is true', () => { - storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + test('does not show create prompt tip when create prompt was already used', () => { + storageService.store('chat.tips.executedCommands', JSON.stringify(['chat.tips.createPrompt.commandUsed']), StorageScope.APPLICATION, StorageTarget.MACHINE); const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); if (!tip) { break; } - assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands when context key is true'); + assert.notStrictEqual(tip.id, 'tip.createPrompt', 'Should not show tip.createPrompt when create-prompt was used'); service.dismissTip(); } }); @@ -1269,36 +1327,6 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after refresh finds instruction files'); }); - test('keeps tip.customInstructions excluded after instruction files were detected once', async () => { - const tip: ITipDefinition = { - id: 'tip.customInstructions', - message: 'test', - excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; - - const tracker1 = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - await new Promise(r => setTimeout(r, 0)); - assert.strictEqual(tracker1.isExcluded(tip), true, 'Should be excluded when instruction files exist'); - - const tracker2 = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - assert.strictEqual(tracker2.isExcluded(tip), true, 'Should remain excluded based on persisted detection signal'); - }); }); suite('CreateSlashCommandsUsageTracker', () => { @@ -1306,13 +1334,13 @@ suite('CreateSlashCommandsUsageTracker', () => { let storageService: InMemoryStorageService; let contextKeyService: MockContextKeyService; - let submitRequestEmitter: Emitter<{ readonly chatSessionResource: URI }>; + let submitRequestEmitter: Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>; let sessions: Map; setup(() => { storageService = testDisposables.add(new InMemoryStorageService()); contextKeyService = new MockContextKeyService(); - submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI }>()); + submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); sessions = new Map(); }); @@ -1425,6 +1453,26 @@ suite('CreateSlashCommandsUsageTracker', () => { ); }); + test('detects create command from submitted message payload when session has no last request', () => { + const sessionResource = URI.parse('chat:session-payload'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + submitRequestEmitter.fire({ + chatSessionResource: sessionResource, + message: { + text: '/create-prompt payload-test', + parts: [], + }, + }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist usage detected from submitted message payload', + ); + }); + test('does not mark used for non-create slash commands', () => { const sessionResource = URI.parse('chat:session4'); const tracker = createTracker(); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 8fb1d803dee..df44c9f3fd4 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -20,7 +20,7 @@ export class MockChatService implements IChatService { _serviceBrand: undefined; editingSessions = []; transferredSessionResource: URI | undefined; - readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }> = Event.None; + readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }> = Event.None; readonly onDidCreateModel: Event = Event.None; private sessions = new ResourceMap();