Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ export class ChatSessionOptionSlashCommandsContribution extends Disposable {
this.logService.warn(`[ChatSessionOptionSlashCommands] Skipping duplicate slash command '${name}' contributed by session type '${chatSessionType}'.`);
continue;
}
if (this.slashCommandService.hasCommand(name)) {
if (this.slashCommandService.hasCommand(name, chatSessionType)) {
this.logService.warn(`[ChatSessionOptionSlashCommands] Slash command '${name}' contributed by session type '${chatSessionType}' is already registered; skipping.`);
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1899,7 +1899,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (e.followup.subCommand) {
msg += `${chatSubcommandLeader}${e.followup.subCommand} `;
}
} else if (!e.followup.agentId && e.followup.subCommand && this.chatSlashCommandService.hasCommand(e.followup.subCommand)) {
} else if (!e.followup.agentId && e.followup.subCommand && this.chatSlashCommandService.hasCommand(e.followup.subCommand, getChatSessionType(this.viewModel.model.sessionResource))) {
msg = `${chatSubcommandLeader}${e.followup.subCommand} `;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,7 @@ export class ChatService extends Disposable implements IChatService {
const agentResult = await this.chatAgentService.invokeAgent(agent.id, requestProps, progressCallback, history, token);
rawResult = agentResult;
agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken);
} else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) {
} else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command, getChatSessionType(model.sessionResource))) {
if (commandPart.slashCommand.silent !== true) {
request = model.addRequest(parsedRequest, { variables: [] }, attempt, options?.modeInfo);
completeResponseCreated();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData, IChatS
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
import { ChatAgentLocation, ChatModeKind } from '../constants.js';
import { URI } from '../../../../../base/common/uri.js';
import { getChatSessionType } from '../model/chatUri.js';
import { matchesSessionType } from '../promptSyntax/service/promptsService.js';

//#region slash service, commands etc

Expand Down Expand Up @@ -63,16 +65,16 @@ export interface IChatSlashCommandService {
registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable;
executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void>;
getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array<IChatSlashData>;
hasCommand(id: string): boolean;
hasCommand(id: string, sessionType: string): boolean;
}

type Tuple = { data: IChatSlashData; command?: IChatSlashCallback };
type RegisteredSlashCommand = { data: IChatSlashData; command?: IChatSlashCallback };

export class ChatSlashCommandService extends Disposable implements IChatSlashCommandService {

declare _serviceBrand: undefined;

private readonly _commands = new Map<string, Tuple>();
private readonly _commands = new Map<string, RegisteredSlashCommand[]>();

private readonly _onDidChangeCommands = this._register(new Emitter<void>());
readonly onDidChangeCommands: Event<void> = this._onDidChangeCommands.event;
Expand All @@ -86,35 +88,68 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom
this._commands.clear();
}

private getSessionScopedCommands(id: string): RegisteredSlashCommand[] {
return this._commands.get(id) ?? [];
}

private commandsOverlap(dataA: IChatSlashData, dataB: IChatSlashData): boolean {
if (dataA.sessionTypes === undefined || dataB.sessionTypes === undefined) {
return true;
}

return dataA.sessionTypes.some(sessionType => dataB.sessionTypes?.includes(sessionType));
}

private getCommand(id: string, sessionType: string | undefined): RegisteredSlashCommand | undefined {
return this.getSessionScopedCommands(id).find(candidate => matchesSessionType(candidate.data.sessionTypes, sessionType));
}

registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable {
if (this._commands.has(data.command)) {
throw new Error(`Already registered a command with id ${data.command}}`);
const commandsForId = this.getSessionScopedCommands(data.command);
if (commandsForId.some(candidate => this.commandsOverlap(candidate.data, data))) {
throw new Error(`Already registered a command with id ${data.command}`);
}
Comment on lines +108 to 111
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplicate-registration error message has an extra } after the command id, which will produce a confusing message (and makes it harder to search/grep for this error). Remove the stray brace and consider quoting the id for clarity.

This issue also appears on line 153 of the same file.

Copilot uses AI. Check for mistakes.

this._commands.set(data.command, { data, command });
const entry = { data, command };
commandsForId.push(entry);
this._commands.set(data.command, commandsForId);
this._onDidChangeCommands.fire();

return toDisposable(() => {
if (this._commands.delete(data.command)) {
this._onDidChangeCommands.fire();
const commandsForId = this._commands.get(data.command);
if (!commandsForId) {
return;
}

const entryIndex = commandsForId.indexOf(entry);
if (entryIndex === -1) {
return;
}

commandsForId.splice(entryIndex, 1);
if (commandsForId.length === 0) {
this._commands.delete(data.command);
}

this._onDidChangeCommands.fire();
});
}

getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array<IChatSlashData> {
return Array
.from(this._commands.values(), v => v.data)
.from(this._commands.values())
.flatMap(commands => commands.map(v => v.data))
.filter(c => c.locations.includes(location) && (!c.modes || c.modes.includes(mode)));
}

hasCommand(id: string): boolean {
return this._commands.has(id);
hasCommand(id: string, sessionType: string): boolean {
return !!this.getCommand(id, sessionType);
}

async executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> {
const data = this._commands.get(id);
const data = this.getCommand(id, getChatSessionType(sessionResource));
if (!data) {
throw new Error('No command with id ${id} NOT registered');
throw new Error(`No command with id ${id} NOT registered`);
}
if (!data.command) {
await this._extensionService.activateByEvent(`onSlash:${id}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { ChatService } from '../../../common/chatService/chatServiceImpl.js';
import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js';
import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js';
import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js';
import { LocalChatSessionUri } from '../../../common/model/chatUri.js';
import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js';
import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js';
import { IConfiguredHooksInfo, IPromptsService } from '../../../common/promptSyntax/service/promptsService.js';
Expand Down Expand Up @@ -228,6 +229,86 @@ suite('ChatService', () => {
});
ensureNoDisposablesAreLeakedInTestSuite();

test('slash commands can share ids across non-overlapping session types', async () => {
const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService));
const executions: string[] = [];
const progress = { report: (_progress: IChatProgress) => { } };

testDisposables.add(slashCommandService.registerSlashCommand({
command: 'switch',
detail: 'Local switch',
locations: [ChatAgentLocation.Chat],
sessionTypes: ['local'],
}, async () => {
executions.push('local');
}));

testDisposables.add(slashCommandService.registerSlashCommand({
command: 'switch',
detail: 'Remote switch',
locations: [ChatAgentLocation.Chat],
sessionTypes: ['remote'],
}, async () => {
executions.push('remote');
}));

assert.strictEqual(slashCommandService.hasCommand('switch', 'local'), true);
assert.strictEqual(slashCommandService.hasCommand('switch', 'remote'), true);
assert.strictEqual(slashCommandService.hasCommand('switch', 'other'), false);

await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, LocalChatSessionUri.forSession('local-session'), CancellationToken.None);
await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, URI.from({ scheme: 'remote', path: '/session' }), CancellationToken.None);

assert.deepStrictEqual(executions, ['local', 'remote']);
});

test('slash commands reject overlapping session types for the same id', () => {
const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService));
const command = async () => undefined;

testDisposables.add(slashCommandService.registerSlashCommand({
command: 'switch',
detail: 'Local switch',
locations: [ChatAgentLocation.Chat],
sessionTypes: ['local', 'remote'],
}, command));

assert.throws(() => slashCommandService.registerSlashCommand({
command: 'switch',
detail: 'Remote switch',
locations: [ChatAgentLocation.Chat],
sessionTypes: ['remote', 'other'],
}, command));
});

test('slash commands without session types apply to all session types', async () => {
const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService));
const executions: string[] = [];
const progress = { report: (_progress: IChatProgress) => { } };

testDisposables.add(slashCommandService.registerSlashCommand({
command: 'switch',
detail: 'All sessions switch',
locations: [ChatAgentLocation.Chat],
}, async () => {
executions.push('all');
}));

assert.strictEqual(slashCommandService.hasCommand('switch', 'local'), true);
assert.strictEqual(slashCommandService.hasCommand('switch', 'remote'), true);

await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, LocalChatSessionUri.forSession('local-session'), CancellationToken.None);
await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, URI.from({ scheme: 'remote', path: '/session' }), CancellationToken.None);

assert.deepStrictEqual(executions, ['all', 'all']);
assert.throws(() => slashCommandService.registerSlashCommand({
command: 'switch',
detail: 'Remote switch',
locations: [ChatAgentLocation.Chat],
sessionTypes: ['remote'],
}, async () => undefined));
});

test('retrieveSession', async () => {
const testService = createChatService();
// Don't add refs to testDisposables so we can control disposal
Expand Down
Loading