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:
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user