tighten tip when conditions, spilt out /create commands (#297555)

This commit is contained in:
Megan Rogge
2026-02-25 00:12:35 -06:00
committed by GitHub
parent b57baf84e3
commit cf6e184fbe
6 changed files with 223 additions and 121 deletions
@@ -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<IChatTipService>('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<string>;
private readonly _usedModes: Set<string>;
@@ -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<string, number>();
private readonly _fileChecksInFlight = new Map<string, Promise<void>>();
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<string>(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<string>();
@@ -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<boolean>(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;
@@ -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();
@@ -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<IChatModel>;
@@ -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,
@@ -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<IChatService> 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<IChatService> 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<string>();
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<ICommandService> 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<ICommandService> 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<string, { lastRequest: { message: { text: string; parts: readonly { kind: string }[] } } | undefined }>;
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();
@@ -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<IChatModel> = Event.None;
private sessions = new ResourceMap<IChatModel>();