fix(desktop): explain missing profile SOUL.md files

The backend already reports {content, exists} from GET
/api/profiles/{name}/soul, but both SOUL.md editors read only content —
a profile whose personality lives in config.yaml
(agent.personalities.<name>) rendered a blank CodeEditor with no hint
that anything was missing or where the persona actually lives (#89436).

Both surfaces (Profiles view editor and the sidebar Edit SOUL.md
dialog) now show a localized notice when exists is false: no SOUL.md
exists yet, instructions below will create one on save, and config.yaml
personality presets are managed separately. The notice clears on a
successful save; an existing empty file or a read error is never
misreported as missing.

i18n: soulMissing added to every full locale (de/es/fr are override
locales and fall back to English). Component tests cover the
missing/empty/read-error distinction and cross-profile leakage; an e2e
spec drives both editors end to end.

The config.yaml read-back fallback and the dashboard source indicator
stay deferred to the personality-precedence work (#82801).

Salvaged from PR #105201 (author preserved).

Fixes #89436
This commit is contained in:
BlackishGreen33
2026-09-24 18:38:22 -05:00
committed by brooklyn!
parent 9f5440e23d
commit 55e27e5dbe
11 changed files with 126 additions and 6 deletions

View File

@@ -0,0 +1,63 @@
import fs from 'node:fs'
import path from 'node:path'
import { writeMockProviderConfig } from '../../../tests-js/scripts/mock-provider-config'
import { setupMockBackend, waitForAppReady } from './fixtures'
import { expect, test } from './test'
test('both SOUL editors explain a missing file and save it without changing personality config', async () => {
test.setTimeout(180_000)
const fixture = await setupMockBackend()
const { page, sandbox, mockUrl } = fixture
const profileName = 'soul-test'
const profileHome = path.join(sandbox.hermesHome, 'profiles', profileName)
fs.mkdirSync(profileHome, { recursive: true })
writeMockProviderConfig(profileHome, mockUrl, undefined, 'personalities:\n helper: "Keep answers brief."')
const configPath = path.join(profileHome, 'config.yaml')
const config = fs.readFileSync(configPath, 'utf8')
const soulPath = path.join(profileHome, 'SOUL.md')
const missing = /No SOUL\.md file exists for this profile/
try {
await waitForAppReady(fixture, 120_000)
await page.getByRole('button', { name: 'Manage profiles…' }).click()
await page.locator(`[data-panel-row="${profileName}"] [data-slot="row-button"]`).click()
await expect(page.locator('.cm-content')).toBeVisible()
// First-use initialization can seed a default SOUL.md. Remove only this
// sandbox profile's file after initialization to exercise the missing state.
fs.rmSync(soulPath, { force: true })
await page.locator('[data-panel-row="default"] [data-slot="row-button"]').click()
await page.locator(`[data-panel-row="${profileName}"] [data-slot="row-button"]`).click()
await expect(page.locator('.cm-content')).toBeVisible()
expect(fs.existsSync(soulPath)).toBe(false)
await expect(page.getByText(missing)).toBeVisible()
await page.screenshot({ path: test.info().outputPath('missing-soul.png') })
// Dismiss without saving: the sidebar editor must report the same state,
// not silently create a file merely because either editor was opened.
await page.getByRole('button', { name: 'Close profiles' }).click()
const square = page.locator('[data-slot="profile-rail"]').getByRole('button', { name: profileName, exact: true })
await square.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Edit SOUL.md…' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText(missing)).toBeVisible()
expect(fs.existsSync(soulPath)).toBe(false)
await page.screenshot({ path: test.info().outputPath('missing-soul-dialog.png') })
const instructions = '# Soul test\n\nGive concise answers.'
await dialog.locator('.cm-content').fill(instructions)
await dialog.getByRole('button', { name: 'Save SOUL.md' }).click()
await expect(dialog).toBeHidden()
expect(fs.readFileSync(soulPath, 'utf8')).toBe(instructions)
expect(fs.readFileSync(configPath, 'utf8')).toBe(config)
await page.getByRole('button', { name: 'Manage profiles…' }).click()
await page.locator(`[data-panel-row="${profileName}"] [data-slot="row-button"]`).click()
await expect(page.locator('.cm-content')).toContainText('Give concise answers.')
await expect(page.getByText(missing)).toHaveCount(0)
} finally {
await fixture.cleanup()
}
})

View File

@@ -720,6 +720,7 @@ function EditSoulDialog({
const { t } = useI18n()
const p = t.profiles
const [content, setContent] = useState('')
const [missing, setMissing] = useState(false)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
@@ -731,9 +732,15 @@ function EditSoulDialog({
let cancelled = false
setLoading(true)
setContent('')
setMissing(false)
getProfileSoul(profileName, scope)
.then(soul => !cancelled && setContent(soul.content))
.then(soul => {
if (!cancelled) {
setContent(soul.content)
setMissing(soul.exists === false)
}
})
.catch(err => !cancelled && notifyError(err, p.failedLoadSoul))
.finally(() => !cancelled && setLoading(false))
@@ -766,6 +773,7 @@ function EditSoulDialog({
{gatewayLabel && profileName ? p.fleet.onGateway(profileName, gatewayLabel) : profileName} · SOUL.md
</DialogTitle>
</DialogHeader>
{missing && <p className="text-xs text-muted-foreground">{p.soulMissing}</p>}
<div className="h-80">
{!loading && profileName && (
<CodeEditor

View File

@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import type * as Nanostores from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { deleteProfile } from '@/hermes'
import { deleteProfile, getProfileSoul, updateProfileSoul } from '@/hermes'
import { retireLocalProfileGateways } from '@/store/gateway'
import { refreshProfiles, selectProfile, setActiveProfile } from '@/store/profile'
import type { ProfileInfo } from '@/types/hermes'
@@ -21,10 +21,11 @@ afterEach(cleanup)
// Real i18n (useI18n falls back to English with no provider), so labels are the
// actual strings — no brittle key snapshot to maintain here.
// CodeEditor is CodeMirror; the detail pane's SOUL editor doesn't matter to
// these behaviors, so stub it out of the jsdom render.
// Keep editor changes and saves observable without CodeMirror's layout APIs.
vi.mock('@/components/chat/code-editor', () => ({
CodeEditor: () => null
CodeEditor: ({ initialValue, onChange }: { initialValue: string; onChange: (value: string) => void }) => (
<textarea aria-label="SOUL.md" defaultValue={initialValue} onChange={event => onChange(event.target.value)} />
)
}))
vi.mock('@/hermes', () => ({
@@ -127,6 +128,39 @@ async function deleteTheNamedProfile() {
}
describe('ProfilesView', () => {
it('shows missing-file guidance only until SOUL.md is saved, not for empty files or read errors', async () => {
vi.mocked(refreshProfiles).mockResolvedValue([makeProfile('default', true), makeProfile(NAMED_PROFILE)])
vi.mocked(getProfileSoul).mockResolvedValueOnce({ content: '', exists: false })
vi.mocked(updateProfileSoul).mockRejectedValueOnce(new Error('Read-only profile'))
await renderProfilesView()
const missing = /No SOUL\.md file exists for this profile/
expect(screen.getByText(missing)).toBeTruthy()
fireEvent.change(screen.getByLabelText('SOUL.md'), { target: { value: '# My instructions' } })
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Save SOUL.md' })))
expect(screen.getByText('Read-only profile')).toBeTruthy()
expect(screen.getByText(missing)).toBeTruthy()
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Save SOUL.md' })))
expect(updateProfileSoul).toHaveBeenLastCalledWith('default', '# My instructions')
expect(screen.queryByText(missing)).toBeNull()
// An existing empty file is not a missing file. Selecting a row remounts
// the editor, so the previous profile's notice must not carry over.
const selectRow = (name: string) =>
screen.getAllByRole('button', { name }).find(button => !button.hasAttribute('aria-haspopup'))!
await act(async () => fireEvent.click(selectRow(NAMED_PROFILE)))
expect(getProfileSoul).toHaveBeenLastCalledWith(NAMED_PROFILE)
expect(screen.queryByText(missing)).toBeNull()
vi.mocked(getProfileSoul).mockRejectedValueOnce(new Error('Could not read SOUL.md'))
await act(async () => fireEvent.click(selectRow('default')))
expect(screen.getByText('Could not read SOUL.md')).toBeTruthy()
expect(screen.queryByText(missing)).toBeNull()
})
it('opens the shared create dialog with the SOUL.md field (parity with the rail)', async () => {
vi.mocked(refreshProfiles).mockResolvedValue([])

View File

@@ -272,6 +272,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
const p = t.profiles
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [missing, setMissing] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
@@ -284,6 +285,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
setError(null)
setContent('')
setOriginal('')
setMissing(false)
void (async () => {
try {
@@ -292,6 +294,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
if (requestRef.current === profileName) {
setContent(soul.content)
setOriginal(soul.content)
setMissing(soul.exists === false)
}
} catch (err) {
if (requestRef.current === profileName) {
@@ -314,6 +317,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
try {
await updateProfileSoul(profileName, content)
setOriginal(content)
setMissing(false)
notify({ kind: 'success', title: p.soulSaved, message: profileName })
} catch (err) {
setError(err instanceof Error ? err.message : p.failedSaveSoul)
@@ -327,7 +331,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<PanelSectionLabel className="text-[0.7rem] tracking-[0.14em]">SOUL.md</PanelSectionLabel>
<p className="text-xs text-muted-foreground">{p.soulDesc}</p>
<p className="text-xs text-muted-foreground">{missing ? p.soulMissing : p.soulDesc}</p>
</div>
{dirty && <span className="text-[0.65rem] text-muted-foreground">{p.unsavedChanges}</span>}
</div>

View File

@@ -1834,6 +1834,8 @@ export const ar = defineLocale({
skillsLabel: 'المهارات',
notSet: 'غير مضبوط',
soulDesc: 'الموجّه (prompt) النظامي وتعليمات الشخصية المضمّنة في هذا الملف الشخصي.',
soulMissing:
'لا يوجد ملف SOUL.md لهذا الملف الشخصي بعد. أضف التعليمات أدناه واحفظ لإنشائه. تُدار إعدادات الشخصية في config.yaml بشكل منفصل.',
soulOptional: 'اختياري',
soulPlaceholder: mode =>
`الموجّه (prompt) النظامي / الشخصية لهذا الملف الشخصي.\nاتركه فارغا للإبقاء على افتراضي ${mode}.`,

View File

@@ -3248,6 +3248,8 @@ export const en: Translations = {
skillsLabel: 'Skills',
notSet: 'Not set',
soulDesc: 'The system prompt and persona instructions baked into this profile.',
soulMissing:
'No SOUL.md file exists for this profile yet. Add instructions below and save to create one. Personality presets in config.yaml are managed separately.',
soulOptional: 'optional',
soulPlaceholder: mode => `The system prompt / persona for this profile.\nLeave blank to keep the ${mode} default.`,
soulPlaceholderCloned: 'cloned',

View File

@@ -2182,6 +2182,8 @@ export const ja = defineLocale({
skillsLabel: 'スキル',
notSet: '未設定',
soulDesc: 'このプロファイルに組み込まれたシステムプロンプトとペルソナの指示。',
soulMissing:
'このプロファイルにはまだ SOUL.md がありません。下に指示を入力して保存すると作成できます。config.yaml のペルソナ設定は別途管理されます。',
soulOptional: '省略可能',
soulPlaceholder: mode =>
`このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`,

View File

@@ -2378,6 +2378,8 @@ export const ru = defineLocale({
skillsLabel: 'Навыки',
notSet: 'Не задано',
soulDesc: 'Системный промпт и инструкции по персоне, встроенные в этот профиль.',
soulMissing:
'В этом профиле ещё нет файла SOUL.md. Введите инструкции ниже и сохраните, чтобы создать его. Настройки персонажей в config.yaml управляются отдельно.',
soulOptional: 'необязательно',
soulPlaceholder: mode =>
`Системный промпт / персона этого профиля.\nОставьте пустым, чтобы сохранить ${mode} по умолчанию.`,

View File

@@ -2723,6 +2723,7 @@ export interface Translations {
skillsLabel: string
notSet: string
soulDesc: string
soulMissing: string
soulOptional: string
soulPlaceholder: (mode: string) => string
soulPlaceholderCloned: string

View File

@@ -2402,6 +2402,7 @@ export const zhHant = defineLocale({
skillsLabel: '技能',
notSet: '未設定',
soulDesc: '內建於此設定檔的系統提示詞與角色指令。',
soulMissing: '此設定檔尚無 SOUL.md 檔案。在下方輸入指令並儲存即可建立。config.yaml 中的人格預設需另外管理。',
soulOptional: '選填',
soulPlaceholder: mode => `此設定檔的系統提示詞 / 角色說明。\n留空則保留${mode}預設值。`,
soulPlaceholderCloned: '複製的',

View File

@@ -3038,6 +3038,7 @@ export const zh = defineLocale({
skillsLabel: '技能',
notSet: '未设置',
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
soulMissing: '此配置档案尚无 SOUL.md 文件。在下方输入指令并保存即可创建。config.yaml 中的人格预设需单独管理。',
soulOptional: '可选',
soulPlaceholder: mode => `此配置档案的系统提示词 / 人格说明。\n留空则保留${mode}默认值。`,
soulPlaceholderCloned: '克隆的',