From 384fa7a57dc350ffffea30c513eff04f9497651b Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 19:12:54 -0700 Subject: [PATCH] fix(native-chat): normalize slash command descriptors consistently --- .../claude-slash-command-catalog.test.ts | 41 +++++++++++++ .../claude/claude-slash-command-catalog.ts | 60 ++++++++++++------- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index 50b103d7f6d..f4374e52e4f 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -156,6 +156,7 @@ it('bounds the row text a provider can put in the picker', () => { { name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) }, { name: 'wrong-type', description: 42, argumentHint: { text: 'no' } }, { name: 'blank', description: ' ' }, + { name: 'long-whitespace', description: `Visible${' '.repeat(201)}` }, { name: 'wrapped', description: 'first line\n second line' } ] }) @@ -163,6 +164,7 @@ it('bounds the row text a provider can put in the picker', () => { { name: 'long', kind: 'command', kindUnspecified: true }, { name: 'wrong-type', kind: 'command', kindUnspecified: true }, { name: 'blank', kind: 'command', kindUnspecified: true }, + { name: 'long-whitespace', kind: 'command', kindUnspecified: true }, { name: 'wrapped', kind: 'command', @@ -172,6 +174,45 @@ it('bounds the row text a provider can put in the picker', () => { ]) }) +it('does not let malformed descriptor names consume the command detail budget', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + ...Array.from({ length: 512 }, (_, index) => ({ + name: `invalid name ${index}`, + description: 'Rejected with its name' + })), + { name: 'goal', description: 'Set or view the goal', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + } + ]) +}) + +it('combines non-empty fields from duplicate descriptors without discarding earlier text', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'goal', description: 'Set or view the goal' }, + { name: 'goal', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + } + ]) +}) + it('carries descriptor text across the name-only stream init that classifies it', () => { const catalog = new ClaudeSlashCommandCatalog(undefined, { commands: [ diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts index 7c8591293ce..2a4d91260f0 100644 --- a/src/main/claude/claude-slash-command-catalog.ts +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -9,6 +9,11 @@ const MAX_ARGUMENT_HINT_LENGTH = 100 /** The provider's own row text for one command, absent when it reported none. */ type CommandDetail = Pick +function commandName(value: unknown): string | undefined { + const name = typeof value === 'string' ? value.trim() : '' + return name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name) ? name : undefined +} + function names(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -18,54 +23,61 @@ function names(value: unknown): string[] { if (seen.size >= MAX_COMMANDS) { break } - const name = typeof entry === 'string' ? entry.trim() : '' - if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) { + const name = commandName(entry) + if (name !== undefined) { seen.add(name) } } return [...seen] } -function descriptorNames(value: unknown): string[] { - return names( - Array.isArray(value) - ? value.map((entry) => (entry !== null && typeof entry === 'object' ? entry.name : undefined)) - : [] - ) -} - /** A single picker row's worth of provider text: unusable values are dropped, not truncated. */ function rowText(value: unknown, maxLength: number): string | undefined { - if (typeof value !== 'string') { + if (typeof value !== 'string' || value.length > maxLength) { return undefined } const collapsed = value.replace(/\s+/gu, ' ').trim() return collapsed.length > 0 && collapsed.length <= maxLength ? collapsed : undefined } -function descriptorDetails(value: unknown): Map { +function descriptorCatalog(value: unknown): { + names: string[] + details: Map +} { + const names: string[] = [] + const seen = new Set() const details = new Map() if (!Array.isArray(value)) { - return details + return { names, details } } for (const entry of value) { - if (details.size >= MAX_COMMANDS) { + if (seen.size >= MAX_COMMANDS) { break } - if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string') { + if (entry === null || typeof entry !== 'object') { continue } - const description = rowText(entry.description, MAX_DESCRIPTION_LENGTH) - const argumentHint = rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH) + const name = commandName(entry.name) + if (name === undefined) { + continue + } + if (!seen.has(name)) { + seen.add(name) + names.push(name) + } + const previous = details.get(name) + const description = previous?.description ?? rowText(entry.description, MAX_DESCRIPTION_LENGTH) + const argumentHint = + previous?.argumentHint ?? rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH) if (description === undefined && argumentHint === undefined) { continue } - details.set(entry.name.trim(), { + details.set(name, { ...(description === undefined ? {} : { description }), ...(argumentHint === undefined ? {} : { argumentHint }) }) } - return details + return { names, details } } function carriesCommandCatalog(message: Record): boolean { @@ -105,9 +117,10 @@ export class ClaudeSlashCommandCatalog { 'commands' in initialization && Array.isArray(initialization.commands) ) { - this.details = descriptorDetails(initialization.commands) + const catalog = descriptorCatalog(initialization.commands) + this.details = catalog.details this.entries = this.describe( - descriptorNames(initialization.commands).map((name) => ({ + catalog.names.map((name) => ({ name, kind: 'command', kindUnspecified: true @@ -143,9 +156,10 @@ export class ClaudeSlashCommandCatalog { message.subtype === 'commands_changed' && Array.isArray(message.commands) ) { - this.details = descriptorDetails(message.commands) + const catalog = descriptorCatalog(message.commands) + this.details = catalog.details next = this.describe( - descriptorNames(message.commands) + catalog.names .filter((name) => !this.hidden.has(name)) .map((name) => this.hasSkillClassification