From b6b3708db5fc50d6157bc707a705f09ded7662f4 Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Sat, 26 Sep 2026 23:33:21 +0530 Subject: [PATCH] fix(desktop): fold personality names like the runtime so only resolvable rows are offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dropdown readers (personalityOptions, personalityNamesFromConfig) listed config keys verbatim via Object.keys, but the runtime folds every key (available_personalities: str(name).strip().lower(), skipping the neutral spellings none/default/neutral). So a root/agent case clash (Catgirl vs catgirl), a whitespace-padded name, or a neutral spelling surfaced a row the runtime never resolves — the user could pick it and get a different (or no) definition than the one shown, with the widened root-block read (#123297) making mixed case the likely input. Add a shared foldPersonalityName + NEUTRAL_PERSONALITY_NAMES in @/lib/personalities mirroring hermes_cli/personality.py, fold+skip in both readers, and route normalizePersonalityValue through it (which also folds the 'neutral' spelling it previously missed). Reuse the existing isPlainObject guard in helpers. Pin the behavior with case-variant + whitespace + neutral-name cases in both test files. Reported by @Enough1122. --- apps/desktop/src/app/settings/helpers.test.ts | 22 +++++++++++++++++++ apps/desktop/src/app/settings/helpers.ts | 12 ++++++++-- apps/desktop/src/lib/chat-runtime.test.ts | 13 +++++++++++ apps/desktop/src/lib/chat-runtime.ts | 16 +++++++++----- apps/desktop/src/lib/personalities.ts | 20 +++++++++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index fa22fb5205..3ba7633016 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -306,6 +306,28 @@ describe('settings helpers', () => { } } }) + + it('folds custom keys like the runtime so only resolvable rows are offered', () => { + // The runtime folds each key (`str(name).strip().lower()`) and drops the neutral + // spellings; without matching that, the dropdown offers a case-variant duplicate, + // a whitespace-padded name, or a neutral name the runtime canonicalises away — + // rows the user can pick but that never load the definition shown (#123297). + const config: HermesConfigRecord = { + personalities: { Catgirl: {}, ' Spaced ': {}, none: {}, Default: {}, NEUTRAL: {} } + } as HermesConfigRecord + const opts = enumOptionsFor('display.personality', '', config)! + + // `Catgirl` folds to the built-in `catgirl` (offered once, not twice). + expect(opts.filter(o => o === 'catgirl')).toHaveLength(1) + expect(opts).not.toContain('Catgirl') + // whitespace folded to the canonical key. + expect(opts).toContain('spaced') + expect(opts).not.toContain(' Spaced ') + // neutral spellings never surface as selectable rows (only the '' sentinel remains). + for (const neutral of ['none', 'Default', 'NEUTRAL', 'default', 'neutral']) { + expect(opts).not.toContain(neutral) + } + }) }) describe('sectionFieldEntries', () => { diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 8556d1a902..643623613e 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -1,3 +1,4 @@ +import { foldPersonalityName } from '@/lib/personalities' import { asText, normalize } from '@/lib/text' import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes' @@ -247,11 +248,18 @@ function personalityOptions(config: HermesConfigRecord): string[] { // the root-level `personalities` block and `agent.personalities` (agent wins on a name // clash). Read both so a root-registered persona the CLI/gateway resolve also appears in // the dropdown (#123297). + // Fold each key the way the runtime does (`available_personalities`: + // `str(name).strip().lower()`, dropping the neutral spellings) so a case-variant, + // whitespace-padded, or neutral-named block never surfaces a row the runtime can't + // resolve, and a root/agent case clash dedupes to one canonical name (#123297). const customNames: string[] = [] for (const key of ['personalities', 'agent.personalities']) { const block = getNested(config, key) - if (block && typeof block === 'object' && !Array.isArray(block)) { - customNames.push(...Object.keys(block as Record)) + if (isPlainObject(block)) { + for (const name of Object.keys(block)) { + const folded = foldPersonalityName(name) + if (folded) customNames.push(folded) + } } } diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts index 33d4eb6308..a353e0e1e5 100644 --- a/apps/desktop/src/lib/chat-runtime.test.ts +++ b/apps/desktop/src/lib/chat-runtime.test.ts @@ -274,4 +274,17 @@ describe('personalityNamesFromConfig', () => { expect(personalityNamesFromConfig({ personalities: ['nope'], agent: { personalities: 'nope' } })).toEqual([]) expect(personalityNamesFromConfig(null)).toEqual([]) }) + + it('folds keys like the runtime: case/whitespace fold and dedupe, neutral names dropped', () => { + // The runtime (`available_personalities`) folds each key `str(name).strip().lower()` + // and skips the neutral spellings, so the dropdown must not offer a row the runtime + // never resolves. `Catgirl` and `catgirl` are one personality; ` Spaced ` resolves + // to `spaced`; `none`/`default`/`neutral` resolve to nothing. + const names = personalityNamesFromConfig({ + personalities: { Catgirl: 'r', ' Spaced ': 'r', none: 'r', Default: 'r', NEUTRAL: 'r' }, + agent: { personalities: { catgirl: 'a' } } + }) + + expect(names).toEqual(['catgirl', 'spaced']) + }) }) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 0c97b4adac..0b528ec326 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -5,10 +5,11 @@ import type { QuickModelOption } from '@/app/chat/composer/types' import type { ClientSessionState } from '@/app/types' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages' -import { normalize } from '@/lib/text' import type { ComposerAttachment } from '@/store/composer' import type { SessionInfo } from '@/types/hermes' +import { foldPersonalityName } from '@/lib/personalities' + export { BUILTIN_PERSONALITIES } from '@/lib/personalities' const THINKING_STATUS_PREFIX_RE = @@ -286,11 +287,16 @@ export function personalityNamesFromConfig(config: unknown): string[] { // built-ins with the root-level `personalities` block, then `agent.personalities` // (agent wins on a name clash). Read both here so a root-registered persona the // CLI/gateway honour also reaches the GUI (#123297). + // Fold each key the way the runtime does (`available_personalities`: + // `str(name).strip().lower()`, dropping neutral spellings) so a case-variant, + // whitespace-padded, or neutral-named block doesn't surface a row the runtime + // can never resolve, and a root/agent case clash dedupes to one canonical name. const names = new Set() for (const block of [root.personalities, agent.personalities]) { if (block && typeof block === 'object' && !Array.isArray(block)) { for (const name of Object.keys(block as Record)) { - names.add(name) + const key = foldPersonalityName(name) + if (key) names.add(key) } } } @@ -299,9 +305,9 @@ export function personalityNamesFromConfig(config: unknown): string[] { } export function normalizePersonalityValue(value: string): string { - const trimmed = normalize(value) - - return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed + // Share the runtime's canonical form with the dropdown reader (foldPersonalityName), + // which also folds the `neutral` spelling this previously missed. + return foldPersonalityName(value) } export function quickModelOptions( diff --git a/apps/desktop/src/lib/personalities.ts b/apps/desktop/src/lib/personalities.ts index c46e3578fd..eab2ea1ce4 100644 --- a/apps/desktop/src/lib/personalities.ts +++ b/apps/desktop/src/lib/personalities.ts @@ -17,3 +17,23 @@ export const BUILTIN_PERSONALITIES = [ 'philosopher', 'hype' ] + +// Spellings the runtime treats as "no personality" — mirrors +// hermes_cli/personality.py NEUTRAL_PERSONALITY_NAMES. A config key with any of +// these (after folding) never resolves to a personality, so it must not be +// offered in the dropdown. +export const NEUTRAL_PERSONALITY_NAMES = new Set(['', 'none', 'default', 'neutral']) + +/** + * Canonical personality key, mirroring hermes_cli/personality.py + * `normalize_personality_name` (`str(name).strip().lower()`, neutral spellings → + * ''). The runtime folds every user-config key this way before resolving it, so + * any reader that lists names for selection must fold identically — otherwise the + * dropdown offers rows the runtime can never resolve (a case-variant duplicate, a + * whitespace-padded name, or a neutral spelling like `none`/`default`/`neutral`). + * Returns '' for a neutral/blank name (caller skips it). + */ +export function foldPersonalityName(name: unknown): string { + const key = String(name ?? '').trim().toLowerCase() + return NEUTRAL_PERSONALITY_NAMES.has(key) ? '' : key +}