fix(desktop): fold personality names like the runtime so only resolvable rows are offered

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.
This commit is contained in:
PRATHAMESH75
2026-09-26 23:33:21 +05:30
committed by brooklyn!
parent afb924d841
commit b6b3708db5
5 changed files with 76 additions and 7 deletions

View File

@@ -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', () => {

View File

@@ -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<string, unknown>))
if (isPlainObject(block)) {
for (const name of Object.keys(block)) {
const folded = foldPersonalityName(name)
if (folded) customNames.push(folded)
}
}
}

View File

@@ -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'])
})
})

View File

@@ -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<string>()
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<string, unknown>)) {
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(

View File

@@ -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
}