From 55e27e5dbef1bb86c93a205dcae8f62457a3a00f Mon Sep 17 00:00:00 2001 From: BlackishGreen33 Date: Thu, 24 Sep 2026 18:38:22 -0500 Subject: [PATCH] fix(desktop): explain missing profile SOUL.md files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) 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 --- apps/desktop/e2e/profile-soul-missing.spec.ts | 63 +++++++++++++++++++ .../src/app/chat/sidebar/profile-switcher.tsx | 10 ++- apps/desktop/src/app/profiles/index.test.tsx | 42 +++++++++++-- apps/desktop/src/app/profiles/index.tsx | 6 +- apps/desktop/src/i18n/ar.ts | 2 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/ru.ts | 2 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 11 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/e2e/profile-soul-missing.spec.ts diff --git a/apps/desktop/e2e/profile-soul-missing.spec.ts b/apps/desktop/e2e/profile-soul-missing.spec.ts new file mode 100644 index 0000000000..efcb68a9fa --- /dev/null +++ b/apps/desktop/e2e/profile-soul-missing.spec.ts @@ -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() + } +}) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index d341580de5..9d3a2af36b 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -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 + {missing &&

{p.soulMissing}

}
{!loading && profileName && ( ({ - CodeEditor: () => null + CodeEditor: ({ initialValue, onChange }: { initialValue: string; onChange: (value: string) => void }) => ( +