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