fix(coding-agent): disambiguate duplicate slash commands, fixes #1061
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -101,6 +101,7 @@ export type {
|
||||
// Commands
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
ResolvedCommand,
|
||||
// Events - Resources
|
||||
ResourcesDiscoverEvent,
|
||||
ResourcesDiscoverResult,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -967,6 +967,10 @@ export interface RegisteredCommand {
|
||||
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ResolvedCommand extends RegisteredCommand {
|
||||
invocationName: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension API
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -97,6 +97,7 @@ export type {
|
||||
ReadToolCallEvent,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
ResolvedCommand,
|
||||
SessionBeforeCompactEvent,
|
||||
SessionBeforeForkEvent,
|
||||
SessionBeforeSwitchEvent,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user