fix(coding-agent): disambiguate duplicate slash commands, fixes #1061

This commit is contained in:
Mario Zechner
2026-03-23 02:33:52 +01:00
parent bb8c9e1389
commit a8a58ff26b
15 changed files with 205 additions and 66 deletions

View File

@@ -2093,7 +2093,7 @@ export class AgentSession {
private _bindExtensionCore(runner: ExtensionRunner): void {
const getCommands = (): SlashCommandInfo[] => {
const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({
name: command.name,
name: command.invocationName,
description: command.description,
source: "extension",
sourceInfo: command.sourceInfo,

View File

@@ -101,6 +101,7 @@ export type {
// Commands
RegisteredCommand,
RegisteredTool,
ResolvedCommand,
// Events - Resources
ResourcesDiscoverEvent,
ResourcesDiscoverResult,

View File

@@ -37,6 +37,7 @@ import type {
ProviderConfig,
RegisteredCommand,
RegisteredTool,
ResolvedCommand,
ResourcesDiscoverEvent,
ResourcesDiscoverResult,
SessionBeforeCompactResult,
@@ -467,41 +468,53 @@ export class ExtensionRunner {
return undefined;
}
private collectRegisteredCommands(diagnostics?: ResourceDiagnostic[]): RegisteredCommand[] {
private resolveRegisteredCommands(): ResolvedCommand[] {
const commands: RegisteredCommand[] = [];
const commandOwners = new Map<string, string>();
const counts = new Map<string, number>();
for (const ext of this.extensions) {
for (const command of ext.commands.values()) {
const existingOwner = commandOwners.get(command.name);
if (existingOwner) {
const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`;
diagnostics?.push({ type: "warning", message, path: ext.path });
if (diagnostics && !this.hasUI()) {
console.warn(message);
}
continue;
}
commandOwners.set(command.name, ext.path);
commands.push(command);
counts.set(command.name, (counts.get(command.name) ?? 0) + 1);
}
}
return commands;
const seen = new Map<string, number>();
const takenInvocationNames = new Set<string>();
return commands.map((command) => {
const occurrence = (seen.get(command.name) ?? 0) + 1;
seen.set(command.name, occurrence);
let invocationName = (counts.get(command.name) ?? 0) > 1 ? `${command.name}:${occurrence}` : command.name;
if (takenInvocationNames.has(invocationName)) {
let suffix = occurrence;
do {
suffix++;
invocationName = `${command.name}:${suffix}`;
} while (takenInvocationNames.has(invocationName));
}
takenInvocationNames.add(invocationName);
return {
...command,
invocationName,
};
});
}
getRegisteredCommands(): RegisteredCommand[] {
const diagnostics: ResourceDiagnostic[] = [];
const commands = this.collectRegisteredCommands(diagnostics);
this.commandDiagnostics = diagnostics;
return commands;
getRegisteredCommands(): ResolvedCommand[] {
this.commandDiagnostics = [];
return this.resolveRegisteredCommands();
}
getCommandDiagnostics(): ResourceDiagnostic[] {
return this.commandDiagnostics;
}
getCommand(name: string): RegisteredCommand | undefined {
return this.collectRegisteredCommands().find((command) => command.name === name);
getCommand(name: string): ResolvedCommand | undefined {
return this.resolveRegisteredCommands().find((command) => command.invocationName === name);
}
/**

View File

@@ -967,6 +967,10 @@ export interface RegisteredCommand {
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
}
export interface ResolvedCommand extends RegisteredCommand {
invocationName: string;
}
// ============================================================================
// Extension API
// ============================================================================

View File

@@ -849,9 +849,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
const conflicts: Array<{ path: string; message: string }> = [];
// Track which extension registered each tool, command, and flag
// Track which extension registered each tool and flag
const toolOwners = new Map<string, string>();
const commandOwners = new Map<string, string>();
const flagOwners = new Map<string, string>();
for (const ext of extensions) {
@@ -868,19 +867,6 @@ export class DefaultResourceLoader implements ResourceLoader {
}
}
// Check commands
for (const commandName of ext.commands.keys()) {
const existingOwner = commandOwners.get(commandName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Command "/${commandName}" conflicts with ${existingOwner}`,
});
} else {
commandOwners.set(commandName, ext.path);
}
}
// Check flags
for (const flagName of ext.flags.keys()) {
const existingOwner = flagOwners.get(flagName);

View File

@@ -97,6 +97,7 @@ export type {
ReadToolCallEvent,
RegisteredCommand,
RegisteredTool,
ResolvedCommand,
SessionBeforeCompactEvent,
SessionBeforeForkEvent,
SessionBeforeSwitchEvent,

View File

@@ -332,7 +332,10 @@ export class InteractiveMode {
.filter((command) => builtinNames.has(command.name))
.map((command) => ({
type: "warning" as const,
message: `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`,
message:
command.invocationName === command.name
? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`
: `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,
path: command.sourceInfo.path,
}));
}
@@ -386,7 +389,7 @@ export class InteractiveMode {
const extensionCommands: SlashCommand[] = (
this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? []
).map((cmd) => ({
name: cmd.name,
name: cmd.invocationName,
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
getArgumentCompletions: cmd.getArgumentCompletions,
}));

View File

@@ -546,7 +546,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) {
commands.push({
name: command.name,
name: command.invocationName,
description: command.description,
source: "extension",
sourceInfo: command.sourceInfo,