feat(settings): add Persona Pack v1 (closes #2345) (#2558)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Hemanth Dhanasekaran
2026-05-25 00:17:34 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent b7b8ba6bb8
commit b399eabc09
33 changed files with 1764 additions and 7 deletions
@@ -149,6 +149,22 @@ const SettingsHome = () => {
),
onClick: () => navigateToSettings('mascot'),
},
{
id: 'persona',
title: t('settings.persona.menuTitle'),
description: t('settings.persona.menuDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
onClick: () => navigateToSettings('persona'),
},
],
},
// Features tile (Screen Awareness / Messaging Channels / Notifications /
@@ -156,6 +156,14 @@ describe('SettingsHome', () => {
expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications');
});
it('navigates to persona settings when Persona is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
await user.click(screen.getByText('Persona').closest('button')!);
expect(mockNavigateToSettings).toHaveBeenCalledWith('persona');
});
it('navigates to /notifications inbox when Alerts is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
@@ -27,4 +27,9 @@ describe('useSettingsNavigation breadcrumbs', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/developer-options'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('persona returns Settings (top-level)', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/persona'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
});
@@ -32,6 +32,7 @@ export type SettingsRoute =
| 'notifications'
| 'notification-routing'
| 'mascot'
| 'persona'
| 'appearance'
| 'intelligence'
| 'webhooks-triggers'
@@ -115,6 +116,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/notifications')) return 'notifications';
if (path.includes('/settings/devices')) return 'devices';
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/persona')) return 'persona';
if (path.includes('/settings/appearance')) return 'appearance';
if (path.includes('/settings/mcp-server')) return 'mcp-server';
return 'home';
@@ -241,6 +243,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'mascot':
return [settingsCrumb];
// Persona panel sits at the top level of Settings.
case 'persona':
return [settingsCrumb];
// Appearance (theme) panel sits at the top level of Settings.
case 'appearance':
return [settingsCrumb];
@@ -0,0 +1,162 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import PersonaPanel from './PersonaPanel';
const {
mockNavigateBack,
mockNavigateToSettings,
readPersonaFileMock,
writePersonaFileMock,
resetPersonaFileMock,
} = vi.hoisted(() => ({
mockNavigateBack: vi.fn(),
mockNavigateToSettings: vi.fn(),
readPersonaFileMock: vi.fn(),
writePersonaFileMock: vi.fn(),
resetPersonaFileMock: vi.fn(),
}));
vi.mock('../../../services/api/personaFilesApi', () => ({
PERSONA_FILE_SOUL: 'SOUL.md',
readPersonaFile: (...args: unknown[]) => readPersonaFileMock(...args),
writePersonaFile: (...args: unknown[]) => writePersonaFileMock(...args),
resetPersonaFile: (...args: unknown[]) => resetPersonaFileMock(...args),
}));
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: mockNavigateBack,
navigateToSettings: mockNavigateToSettings,
breadcrumbs: [{ label: 'Settings' }],
}),
}));
const soulFile = (overrides: Record<string, unknown> = {}) => ({
filename: 'SOUL.md',
contents: 'You are helpful.',
is_default: true,
...overrides,
});
describe('PersonaPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
readPersonaFileMock.mockResolvedValue(soulFile());
writePersonaFileMock.mockImplementation((_name: string, contents: string) =>
Promise.resolve(soulFile({ contents, is_default: false }))
);
resetPersonaFileMock.mockResolvedValue(
soulFile({ contents: 'default soul', is_default: true })
);
});
it('loads SOUL.md contents into the editor on mount', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => {
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('You are helpful.');
});
expect(readPersonaFileMock).toHaveBeenCalledWith('SOUL.md');
});
it('persists the display name to the store on save', async () => {
const { store } = renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
fireEvent.change(screen.getByTestId('persona-display-name-input'), {
target: { value: 'Nova' },
});
fireEvent.change(screen.getByTestId('persona-description-input'), {
target: { value: 'Calm and concise.' },
});
fireEvent.click(screen.getByTestId('persona-identity-save'));
expect(store.getState().persona.displayName).toBe('Nova');
expect(store.getState().persona.description).toBe('Calm and concise.');
});
it('keeps the identity save button disabled until a field changes', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
expect(screen.getByTestId('persona-identity-save')).toBeDisabled();
});
it('writes edited SOUL.md contents over RPC', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
fireEvent.change(screen.getByTestId('persona-soul-editor'), {
target: { value: 'You are calm and concise.' },
});
fireEvent.click(screen.getByTestId('persona-soul-save'));
await waitFor(() => {
expect(writePersonaFileMock).toHaveBeenCalledWith('SOUL.md', 'You are calm and concise.');
});
});
it('surfaces a save error when the write RPC fails', async () => {
writePersonaFileMock.mockRejectedValue(new Error('disk full'));
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
fireEvent.change(screen.getByTestId('persona-soul-editor'), { target: { value: 'edited' } });
fireEvent.click(screen.getByTestId('persona-soul-save'));
await waitFor(() => {
expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('disk full');
});
});
it('surfaces a reset error when the reset RPC fails', async () => {
readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false }));
resetPersonaFileMock.mockRejectedValue(new Error('reset boom'));
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom'));
fireEvent.click(screen.getByTestId('persona-soul-reset'));
await waitFor(() => {
expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('reset boom');
});
});
it('resets SOUL.md to the bundled default', async () => {
// Start from a non-default file so the Reset button is enabled.
readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false }));
renderWithProviders(<PersonaPanel />);
await waitFor(() => {
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom');
});
fireEvent.click(screen.getByTestId('persona-soul-reset'));
await waitFor(() => {
expect(resetPersonaFileMock).toHaveBeenCalledWith('SOUL.md');
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('default soul');
});
});
it('disables Reset while the file is already the bundled default', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
expect(screen.getByTestId('persona-soul-reset')).toBeDisabled();
expect(screen.getByTestId('persona-soul-default-badge')).toBeInTheDocument();
});
it('surfaces a load error', async () => {
readPersonaFileMock.mockRejectedValue(new Error('boom'));
renderWithProviders(<PersonaPanel />);
await waitFor(() => {
expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('boom');
});
});
it('navigates to mascot settings for avatar & voice', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('persona-open-mascot'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot');
});
});
@@ -0,0 +1,281 @@
import debug from 'debug';
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
PERSONA_FILE_SOUL,
readPersonaFile,
resetPersonaFile,
writePersonaFile,
} from '../../../services/api/personaFilesApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
MAX_PERSONA_DESCRIPTION_LEN,
MAX_PERSONA_DISPLAY_NAME_LEN,
selectPersonaDescription,
selectPersonaDisplayName,
setPersonaDescription,
setPersonaDisplayName,
} from '../../../store/personaSlice';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('persona:panel');
const PersonaPanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const dispatch = useAppDispatch();
const storedDisplayName = useAppSelector(selectPersonaDisplayName);
const storedDescription = useAppSelector(selectPersonaDescription);
const [nameDraft, setNameDraft] = useState(storedDisplayName);
const [descriptionDraft, setDescriptionDraft] = useState(storedDescription);
// Re-sync drafts when the store is reset externally (e.g. resetUserScopedState
// during an identity flip) so Save can't write stale values into a clean store.
useEffect(() => {
setNameDraft(storedDisplayName);
}, [storedDisplayName]);
useEffect(() => {
setDescriptionDraft(storedDescription);
}, [storedDescription]);
// SOUL.md editor state. The file is loaded over RPC on mount; `isDefault`
// tracks whether the current on-disk copy is the bundled prompt so the UI can
// disable Reset when there is nothing to restore.
const [soulDraft, setSoulDraft] = useState('');
const [soulSaved, setSoulSaved] = useState('');
const [soulIsDefault, setSoulIsDefault] = useState(true);
const [soulLoading, setSoulLoading] = useState(true);
const [soulError, setSoulError] = useState<string | null>(null);
const [soulBusy, setSoulBusy] = useState(false);
useEffect(() => {
let cancelled = false;
log('[ui-flow] soul.load:start file=%s', PERSONA_FILE_SOUL);
readPersonaFile(PERSONA_FILE_SOUL)
.then(file => {
if (cancelled) return;
setSoulDraft(file.contents);
setSoulSaved(file.contents);
setSoulIsDefault(file.is_default);
setSoulError(null);
log('[ui-flow] soul.load:ok is_default=%s', file.is_default);
})
.catch((err: unknown) => {
if (cancelled) return;
log('[ui-flow] soul.load:error %s', err instanceof Error ? err.message : err);
setSoulError(err instanceof Error ? err.message : 'Could not load SOUL.md');
})
.finally(() => {
if (!cancelled) setSoulLoading(false);
});
return () => {
cancelled = true;
};
// Load once on mount — `t` is intentionally excluded so a locale change
// does not re-fetch and overwrite unsaved edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const nameDirty = nameDraft.trim() !== storedDisplayName;
const descriptionDirty = descriptionDraft.trim() !== storedDescription;
const identityDirty = nameDirty || descriptionDirty;
const onSaveIdentity = () => {
if (nameDirty) dispatch(setPersonaDisplayName(nameDraft));
if (descriptionDirty) dispatch(setPersonaDescription(descriptionDraft));
};
const soulDirty = soulDraft !== soulSaved;
const onSaveSoul = async () => {
setSoulBusy(true);
setSoulError(null);
log('[ui-flow] soul.save:start bytes=%d', soulDraft.length);
try {
const file = await writePersonaFile(PERSONA_FILE_SOUL, soulDraft);
setSoulDraft(file.contents);
setSoulSaved(file.contents);
setSoulIsDefault(file.is_default);
log('[ui-flow] soul.save:ok');
} catch (err) {
log('[ui-flow] soul.save:error %s', err instanceof Error ? err.message : err);
setSoulError(err instanceof Error ? err.message : t('settings.persona.soul.saveError'));
} finally {
setSoulBusy(false);
}
};
const onResetSoul = async () => {
setSoulBusy(true);
setSoulError(null);
log('[ui-flow] soul.reset:start');
try {
const file = await resetPersonaFile(PERSONA_FILE_SOUL);
setSoulDraft(file.contents);
setSoulSaved(file.contents);
setSoulIsDefault(file.is_default);
log('[ui-flow] soul.reset:ok');
} catch (err) {
log('[ui-flow] soul.reset:error %s', err instanceof Error ? err.message : err);
setSoulError(err instanceof Error ? err.message : t('settings.persona.soul.resetError'));
} finally {
setSoulBusy(false);
}
};
return (
<div>
<SettingsHeader
title={t('settings.persona.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
{/* ── Identity ─────────────────────────────────────────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.persona.identityHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('settings.persona.displayNameLabel')}
</span>
<input
aria-label={t('settings.persona.displayNameLabel')}
data-testid="persona-display-name-input"
value={nameDraft}
maxLength={MAX_PERSONA_DISPLAY_NAME_LEN}
placeholder={t('settings.persona.displayNamePlaceholder')}
onChange={e => setNameDraft(e.target.value)}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('settings.persona.descriptionLabel')}
</span>
<textarea
aria-label={t('settings.persona.descriptionLabel')}
data-testid="persona-description-input"
value={descriptionDraft}
maxLength={MAX_PERSONA_DESCRIPTION_LEN}
rows={3}
placeholder={t('settings.persona.descriptionPlaceholder')}
onChange={e => setDescriptionDraft(e.target.value)}
className="w-full resize-y rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<div className="flex justify-end">
<button
type="button"
data-testid="persona-identity-save"
onClick={onSaveIdentity}
disabled={!identityDirty}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{t('common.save')}
</button>
</div>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.persona.identityDesc')}
</p>
</div>
{/* ── Personality (SOUL.md) ────────────────────────────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.persona.soul.heading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
{soulLoading ? (
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('common.loading')}</p>
) : (
<>
<textarea
aria-label={t('settings.persona.soul.editorLabel')}
data-testid="persona-soul-editor"
value={soulDraft}
rows={12}
spellCheck={false}
onChange={e => setSoulDraft(e.target.value)}
className="w-full resize-y rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs leading-relaxed text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-testid="persona-soul-save"
onClick={() => void onSaveSoul()}
disabled={soulBusy || !soulDirty}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{t('common.save')}
</button>
<button
type="button"
data-testid="persona-soul-reset"
onClick={() => void onResetSoul()}
disabled={soulBusy || soulIsDefault}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
{t('settings.persona.soul.reset')}
</button>
{soulIsDefault && (
<span
data-testid="persona-soul-default-badge"
className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.persona.soul.usingDefault')}
</span>
)}
</div>
</>
)}
{soulError && (
<p
data-testid="persona-soul-error"
className="text-xs text-coral-700 dark:text-coral-300">
{soulError}
</p>
)}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.persona.soul.desc')}
</p>
</div>
{/* ── Appearance & Voice (handled in Mascot settings) ──────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.persona.appearanceHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4">
<button
type="button"
data-testid="persona-open-mascot"
onClick={() => navigateToSettings('mascot')}
className="flex w-full items-center justify-between text-left text-sm text-stone-700 dark:text-neutral-200 hover:text-primary-700 dark:hover:text-primary-300">
<span>{t('settings.persona.openMascotSettings')}</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.persona.appearanceDesc')}
</p>
</div>
</div>
</div>
);
};
export default PersonaPanel;
+24
View File
@@ -504,6 +504,30 @@ const ar5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'ملف التكوين',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP محدد العميل',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': 'وضع التوجيه ومشغلات التكامل وأرشيف محفوظات التشغيل.',
'settings.appearance.tabBarHeading': 'شريط علامات التبويب السفلي',
+24
View File
@@ -511,6 +511,30 @@ const bn5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'কনফিগার ফাইল',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP ক্লায়েন্ট নির্বাচক',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': 'রাউটিং মোড, ইন্টিগ্রেশন ট্রিগার, এবং ট্রিগার ইতিহাস।',
'settings.appearance.tabBarHeading': 'নীচের ট্যাব বার',
+24
View File
@@ -533,6 +533,30 @@ const de5: TranslationMap = {
'settings.mascot.colorYellow': 'Gelb',
'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar',
'settings.mascot.title': 'OpenHuman',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Routing-Modus, Integrations-Trigger und Trigger-Verlaufsarchiv.',
+24
View File
@@ -517,6 +517,30 @@ const en5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'Config file',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'common.breadcrumb': 'Breadcrumb',
'settings.betaBuild': 'Beta build - v{version}',
'migration.vendor.openclaw': 'OpenClaw',
+24
View File
@@ -520,6 +520,30 @@ const es5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'Archivo de configuración',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP selector de cliente',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Modo de enrutamiento, activadores de integración y archivo de historial de activadores.',
+24
View File
@@ -524,6 +524,30 @@ const fr5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'Fichier de configuration',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP sélecteur de client',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
"Mode de routage, déclencheurs d'intégration et archive de l'historique des déclencheurs.",
+24
View File
@@ -514,6 +514,30 @@ const hi5: TranslationMap = {
'settings.mcpServer.clientZed': 'जेड',
'settings.mcpServer.configFilePath': 'कॉन्फ़िग फ़ाइल',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP ग्राहक चयनकर्ता',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': 'रूटिंग मोड, एकीकरण ट्रिगर, और ट्रिगर इतिहास संग्रह।',
'settings.appearance.tabBarHeading': 'निचला टैब बार',
+24
View File
@@ -514,6 +514,30 @@ const id5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'File konfigurasi',
'settings.mcpServer.clientSelectorAriaLabel': 'Pemilih klien MCP',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Mode perutean, pemicu integrasi, dan arsip riwayat pemicu.',
+24
View File
@@ -520,6 +520,30 @@ const it5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'File di configurazione',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP selettore client',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Modalità di routing, trigger di integrazione e archivio cronologico dei trigger.',
+24
View File
@@ -473,6 +473,30 @@ const ko5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': '구성 파일',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP 클라이언트 선택기',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.ai.title': 'AI 구성',
'settings.developerMenu.ai.desc': '클라우드 공급자, 로컬 Ollama 모델 및 워크로드별 라우팅',
'settings.developerMenu.screenAwareness.title': '화면 인식',
+24
View File
@@ -521,6 +521,30 @@ const pt5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'Arquivo de configuração',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP seletor de cliente',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Modo de roteamento, gatilhos de integração e arquivo de histórico de gatilhos.',
+24
View File
@@ -517,6 +517,30 @@ const ru5: TranslationMap = {
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'Файл конфигурации',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP селектор клиента',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Режим маршрутизации, триггеры интеграции и архив истории триггеров.',
+24
View File
@@ -483,6 +483,30 @@ const zhCN5: TranslationMap = {
'settings.mcpServer.clientZed': '泽德',
'settings.mcpServer.configFilePath': '配置文件',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP 客户端选择器',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': '路由模式、集成触发器和触发器历史存档。',
'settings.appearance.tabBarHeading': '底部标签栏',
+24
View File
@@ -3048,6 +3048,30 @@ const en: TranslationMap = {
'settings.mascot.voice.useLocaleDefault': 'Match the app language',
'settings.mascot.voice.useLocaleDefaultDesc':
'Auto-pick a voice for the current interface language.',
'settings.persona.title': 'Persona',
'settings.persona.menuTitle': 'Persona',
'settings.persona.menuDesc':
'Name, personality, avatar, and voice — your assistant as one identity',
'settings.persona.identityHeading': 'Identity',
'settings.persona.identityDesc':
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
'settings.persona.displayNameLabel': 'Display name',
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
'settings.persona.descriptionLabel': 'Description',
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
'settings.persona.soul.heading': 'Personality (SOUL.md)',
'settings.persona.soul.desc':
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
'settings.persona.soul.editorLabel': 'SOUL.md contents',
'settings.persona.soul.reset': 'Reset to default',
'settings.persona.soul.usingDefault': 'Using the bundled default',
'settings.persona.soul.loadError': 'Could not load SOUL.md',
'settings.persona.soul.saveError': 'Could not save SOUL.md',
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
'settings.persona.appearanceHeading': 'Avatar & Voice',
'settings.persona.appearanceDesc':
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
'settings.persona.openMascotSettings': 'Open Mascot settings',
'settings.memoryWindow.balanced.badge': 'Recommended',
'settings.memoryWindow.balanced.hint':
'Sensible default — good continuity without burning extra tokens on every run.',
+2
View File
@@ -26,6 +26,7 @@ import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
import MigrationPanel from '../components/settings/panels/MigrationPanel';
import NotificationsTabbedPanel from '../components/settings/panels/NotificationsTabbedPanel';
import PersonaPanel from '../components/settings/panels/PersonaPanel';
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
@@ -408,6 +409,7 @@ const Settings = () => {
<Route path="voice" element={wrapSettingsPage(<VoicePanel />)} />
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
<Route path="mascot" element={wrapSettingsPage(<MascotPanel />)} />
<Route path="persona" element={wrapSettingsPage(<PersonaPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
PERSONA_FILE_SOUL,
readPersonaFile,
resetPersonaFile,
type WorkspaceFile,
writePersonaFile,
} from './personaFilesApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const file: WorkspaceFile = {
filename: PERSONA_FILE_SOUL,
contents: 'You are helpful.',
is_default: false,
};
describe('personaFilesApi', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('readPersonaFile calls the read RPC with the filename and returns the file', async () => {
mockCallCoreRpc.mockResolvedValueOnce(file);
const result = await readPersonaFile('SOUL.md');
expect(result).toEqual(file);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.workspace_file_read',
params: { filename: 'SOUL.md' },
});
});
it('writePersonaFile passes filename + contents to the write RPC', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ ...file, contents: 'new' });
const result = await writePersonaFile('SOUL.md', 'new');
expect(result.contents).toBe('new');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.workspace_file_write',
params: { filename: 'SOUL.md', contents: 'new' },
});
});
it('resetPersonaFile calls the reset RPC with the filename', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ ...file, is_default: true });
const result = await resetPersonaFile('SOUL.md');
expect(result.is_default).toBe(true);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.workspace_file_reset',
params: { filename: 'SOUL.md' },
});
});
it('propagates RPC errors from each method', async () => {
mockCallCoreRpc.mockRejectedValue(new Error('boom'));
await expect(readPersonaFile('SOUL.md')).rejects.toThrow('boom');
await expect(writePersonaFile('SOUL.md', 'x')).rejects.toThrow('boom');
await expect(resetPersonaFile('SOUL.md')).rejects.toThrow('boom');
});
it('handles non-Error rejections without throwing in the logger', async () => {
mockCallCoreRpc.mockRejectedValue('stringy failure');
await expect(readPersonaFile('SOUL.md')).rejects.toBe('stringy failure');
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Persona file façade for the Settings → Persona panel (issue #2345).
*
* Wraps the core's workspace-file JSON-RPC surface so the panel never imports
* `coreRpcClient` directly. Only the bundled persona prompt files are editable;
* the core enforces the same allowlist, so an unknown name is rejected on both
* sides.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('persona:files');
/** Files the Persona panel may read / edit / reset. Mirrors the core allowlist. */
export const PERSONA_FILE_SOUL = 'SOUL.md';
/** Shape returned by every `openhuman.workspace_file_*` method. */
export interface WorkspaceFile {
filename: string;
contents: string;
/** True when the contents are the bundled default (missing on read, or reset). */
is_default: boolean;
}
export async function readPersonaFile(filename: string): Promise<WorkspaceFile> {
log('[rpc] read:start file=%s', filename);
try {
const file = await callCoreRpc<WorkspaceFile>({
method: 'openhuman.workspace_file_read',
params: { filename },
});
log(
'[rpc] read:ok file=%s is_default=%s bytes=%d',
filename,
file.is_default,
file.contents.length
);
return file;
} catch (err) {
log('[rpc] read:error file=%s err=%s', filename, err instanceof Error ? err.message : err);
throw err;
}
}
export async function writePersonaFile(filename: string, contents: string): Promise<WorkspaceFile> {
log('[rpc] write:start file=%s bytes=%d', filename, contents.length);
try {
const file = await callCoreRpc<WorkspaceFile>({
method: 'openhuman.workspace_file_write',
params: { filename, contents },
});
log('[rpc] write:ok file=%s', filename);
return file;
} catch (err) {
log('[rpc] write:error file=%s err=%s', filename, err instanceof Error ? err.message : err);
throw err;
}
}
export async function resetPersonaFile(filename: string): Promise<WorkspaceFile> {
log('[rpc] reset:start file=%s', filename);
try {
const file = await callCoreRpc<WorkspaceFile>({
method: 'openhuman.workspace_file_reset',
params: { filename },
});
log('[rpc] reset:ok file=%s', filename);
return file;
} catch (err) {
log('[rpc] reset:error file=%s err=%s', filename, err instanceof Error ? err.message : err);
throw err;
}
}
+8
View File
@@ -22,6 +22,7 @@ import coreModeReducer from './coreModeSlice';
import localeReducer from './localeSlice';
import mascotReducer from './mascotSlice';
import notificationReducer from './notificationSlice';
import personaReducer from './personaSlice';
import providerSurfacesReducer from './providerSurfaceSlice';
import socketReducer from './socketSlice';
import themeReducer from './themeSlice';
@@ -142,6 +143,12 @@ const mascotPersistConfig = {
};
const persistedMascotReducer = persistReducer(mascotPersistConfig, mascotReducer);
// Persona Pack v1 (issue #2345): persist the cosmetic display name + description
// per user, mirroring how mascot appearance is stored. SOUL.md lives on disk and
// is round-tripped over RPC, so it is intentionally not in this slice.
const personaPersistConfig = { key: 'persona', storage, whitelist: ['displayName', 'description'] };
const persistedPersonaReducer = persistReducer(personaPersistConfig, personaReducer);
export const store = configureStore({
reducer: {
socket: socketReducer,
@@ -157,6 +164,7 @@ export const store = configureStore({
coreMode: persistedCoreModeReducer,
locale: persistedLocaleReducer,
mascot: persistedMascotReducer,
persona: persistedPersonaReducer,
theme: persistedThemeReducer,
},
middleware: getDefaultMiddleware => {
+114
View File
@@ -0,0 +1,114 @@
import { REHYDRATE } from 'redux-persist';
import { describe, expect, it } from 'vitest';
import reducer, {
MAX_PERSONA_DESCRIPTION_LEN,
MAX_PERSONA_DISPLAY_NAME_LEN,
resetPersona,
selectPersonaDescription,
selectPersonaDisplayName,
setPersonaDescription,
setPersonaDisplayName,
} from './personaSlice';
import { resetUserScopedState } from './resetActions';
describe('personaSlice', () => {
it('starts empty', () => {
const state = reducer(undefined, { type: '@@INIT' });
expect(state.displayName).toBe('');
expect(state.description).toBe('');
});
it('setPersonaDisplayName trims and stores the value', () => {
const state = reducer(undefined, setPersonaDisplayName(' Nova '));
expect(state.displayName).toBe('Nova');
});
it('setPersonaDescription trims and stores the value', () => {
const state = reducer(undefined, setPersonaDescription(' Calm and concise. '));
expect(state.description).toBe('Calm and concise.');
});
it('rejects an over-length display name, leaving the prior value intact', () => {
const seeded = reducer(undefined, setPersonaDisplayName('Nova'));
const tooLong = 'x'.repeat(MAX_PERSONA_DISPLAY_NAME_LEN + 1);
const after = reducer(seeded, setPersonaDisplayName(tooLong));
expect(after.displayName).toBe('Nova');
});
it('rejects an over-length description, leaving the prior value intact', () => {
const seeded = reducer(undefined, setPersonaDescription('original'));
const tooLong = 'x'.repeat(MAX_PERSONA_DESCRIPTION_LEN + 1);
const after = reducer(seeded, setPersonaDescription(tooLong));
expect(after.description).toBe('original');
});
it('accepts a display name exactly at the length limit', () => {
const atLimit = 'x'.repeat(MAX_PERSONA_DISPLAY_NAME_LEN);
const state = reducer(undefined, setPersonaDisplayName(atLimit));
expect(state.displayName).toBe(atLimit);
});
it('allows clearing a field with an empty string', () => {
const seeded = reducer(undefined, setPersonaDisplayName('Nova'));
const cleared = reducer(seeded, setPersonaDisplayName(' '));
expect(cleared.displayName).toBe('');
});
it('resetPersona clears both fields', () => {
let state = reducer(undefined, setPersonaDisplayName('Nova'));
state = reducer(state, setPersonaDescription('Calm.'));
const after = reducer(state, resetPersona());
expect(after.displayName).toBe('');
expect(after.description).toBe('');
});
it('resets to initial state on resetUserScopedState', () => {
let state = reducer(undefined, setPersonaDisplayName('Nova'));
state = reducer(state, setPersonaDescription('Calm.'));
const after = reducer(state, resetUserScopedState());
expect(after.displayName).toBe('');
expect(after.description).toBe('');
});
describe('REHYDRATE', () => {
it('restores valid persisted fields', () => {
const state = reducer(undefined, {
type: REHYDRATE,
key: 'persona',
payload: { displayName: ' Nova ', description: ' Calm. ' },
});
expect(state.displayName).toBe('Nova');
expect(state.description).toBe('Calm.');
});
it('scrubs over-length or non-string persisted fields to empty', () => {
const state = reducer(undefined, {
type: REHYDRATE,
key: 'persona',
payload: { displayName: 'x'.repeat(MAX_PERSONA_DISPLAY_NAME_LEN + 1), description: 42 },
});
expect(state.displayName).toBe('');
expect(state.description).toBe('');
});
it('ignores rehydrate for other slices', () => {
const seeded = reducer(undefined, setPersonaDisplayName('Nova'));
const after = reducer(seeded, {
type: REHYDRATE,
key: 'mascot',
payload: { displayName: 'overwritten' },
});
expect(after.displayName).toBe('Nova');
});
});
describe('selectors', () => {
it('read the current fields', () => {
let persona = reducer(undefined, setPersonaDisplayName('Nova'));
persona = reducer(persona, setPersonaDescription('Calm.'));
expect(selectPersonaDisplayName({ persona })).toBe('Nova');
expect(selectPersonaDescription({ persona })).toBe('Calm.');
});
});
});
+92
View File
@@ -0,0 +1,92 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { REHYDRATE } from 'redux-persist';
import { resetUserScopedState } from './resetActions';
/**
* Persona Pack v1 (issue #2345) — lightweight identity metadata the user sets
* for their assistant. The actual personality lives in the editable `SOUL.md`
* prompt (handled over RPC in the Persona settings panel); this slice only
* holds the cosmetic display name + description, persisted locally the same way
* mascot preferences are.
*/
/**
* Caps on the persisted persona strings. These are display fields, not prompts,
* so the limits are generous-but-bounded purely to stop a stray multi-megabyte
* paste from landing in localStorage. Oversize input is rejected at the reducer
* boundary rather than truncated, so the user keeps editing their draft.
*/
export const MAX_PERSONA_DISPLAY_NAME_LEN = 80;
export const MAX_PERSONA_DESCRIPTION_LEN = 500;
export interface PersonaState {
/** User-facing name for the assistant persona, or `''` when unset. */
displayName: string;
/** Short free-text description of the persona, or `''` when unset. */
description: string;
}
const initialState: PersonaState = { displayName: '', description: '' };
/**
* Normalize a persona string: coerce non-strings to `''`, trim, and reject
* (return `null`) when it exceeds `maxLen` so the caller can leave the prior
* value in place. An empty/whitespace-only string is a valid "cleared" value.
*/
function normalizePersonaField(value: unknown, maxLen: number): string | null {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
if (trimmed.length > maxLen) return null;
return trimmed;
}
const personaSlice = createSlice({
name: 'persona',
initialState,
reducers: {
setPersonaDisplayName(state, action: PayloadAction<string>) {
const next = normalizePersonaField(action.payload, MAX_PERSONA_DISPLAY_NAME_LEN);
if (next !== null) state.displayName = next;
},
setPersonaDescription(state, action: PayloadAction<string>) {
const next = normalizePersonaField(action.payload, MAX_PERSONA_DESCRIPTION_LEN);
if (next !== null) state.description = next;
},
/** Clear both persona fields back to their unset defaults. */
resetPersona(state) {
state.displayName = '';
state.description = '';
},
},
extraReducers: builder => {
builder.addCase(resetUserScopedState, () => initialState);
// Scrub anything unexpected (corrupted blob, a future build that dropped a
// field) on rehydrate so the persisted value can never poison the UI.
builder.addCase(REHYDRATE, (state, action) => {
const rehydrateAction = action as {
type: typeof REHYDRATE;
key: string;
payload?: { displayName?: unknown; description?: unknown };
};
if (rehydrateAction.key !== 'persona') return;
state.displayName =
normalizePersonaField(rehydrateAction.payload?.displayName, MAX_PERSONA_DISPLAY_NAME_LEN) ??
'';
state.description =
normalizePersonaField(rehydrateAction.payload?.description, MAX_PERSONA_DESCRIPTION_LEN) ??
'';
});
},
});
export const { setPersonaDisplayName, setPersonaDescription, resetPersona } = personaSlice.actions;
export const selectPersonaDisplayName = (state: { persona: PersonaState }): string =>
state.persona.displayName;
export const selectPersonaDescription = (state: { persona: PersonaState }): string =>
state.persona.description;
export { personaSlice };
export default personaSlice.reducer;
+5 -1
View File
@@ -16,6 +16,7 @@ import connectivityReducer from '../store/connectivitySlice';
import coreModeReducer from '../store/coreModeSlice';
import localeReducer from '../store/localeSlice';
import mascotReducer from '../store/mascotSlice';
import personaReducer from '../store/personaSlice';
import socketReducer from '../store/socketSlice';
/**
@@ -24,7 +25,9 @@ import socketReducer from '../store/socketSlice';
*
* `mascot` is wired in for the mascot voice picker (issue #1762): the
* VoicePanel reads + dispatches against this slice, and useSelector
* would throw on a missing reducer without a stub here.
* would throw on a missing reducer without a stub here. `persona` is wired
* in for the same reason (issue #2345): PersonaPanel reads + dispatches
* against it.
*/
const testRootReducer = combineReducers({
channelConnections: channelConnectionsReducer,
@@ -33,6 +36,7 @@ const testRootReducer = combineReducers({
coreMode: coreModeReducer,
locale: localeReducer,
mascot: mascotReducer,
persona: personaReducer,
socket: socketReducer,
});
+10 -2
View File
@@ -220,6 +220,14 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| 5.3.3 | Voice Command Execution | WD | `voice-mode.spec.ts` | ✅ | |
| 5.3.4 | Mascot Voice Selection | VU | `app/src/store/__tests__/mascotSlice.test.ts`, `app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`, `app/src/features/human/useHumanMascot.test.ts` (this PR) | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (#1762), `synthesizeSpeech` voiceId override propagation |
### 5.4 Persona
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ----------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------- |
| 5.4.1 | Persona Name & Description | VU | `app/src/store/personaSlice.test.ts`, `app/src/components/settings/panels/PersonaPanel.test.tsx` (this PR) | ✅ | Slice validation + persist REHYDRATE scrub; Settings identity fields persist on save (#2345) |
| 5.4.2 | SOUL.md Edit & Reset | RU+VU | `src/openhuman/workspace/rpc.rs`, `app/src/components/settings/panels/PersonaPanel.test.tsx` (this PR) | ✅ | Core read/write/reset with allowlist + size cap; panel loads, saves, resets over RPC (#2345) |
| 5.4.3 | Persona Settings Surface | VU | `app/src/components/settings/panels/PersonaPanel.test.tsx` (this PR) | ✅ | Bundles identity + SOUL.md + link to Mascot avatar/voice (#2345) |
---
## 6. System Tools & Agent Capabilities
@@ -494,11 +502,11 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| Status | Count |
| ---------------- | ------------------------------------------------ |
| ✅ Covered | 66 |
| ✅ Covered | 69 |
| 🟡 Partial | 27 |
| ❌ Missing | 26 |
| 🚫 Manual smoke | 11 |
| **Total leaves** | **131 explicit + nested = 202 product features** |
| **Total leaves** | **134 explicit + nested = 205 product features** |
PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test.
Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke).
+10
View File
@@ -1022,6 +1022,16 @@ const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Stable,
privacy: None,
},
Capability {
id: "settings.persona_pack",
name: "Persona Pack",
domain: "settings",
category: CapabilityCategory::Settings,
description: "Personalize the assistant as one identity: set a display name and description, edit or reset the SOUL.md personality prompt, and reach mascot avatar and voice settings — all from a single Persona surface.",
how_to: "Settings > Persona",
status: CapabilityStatus::Beta,
privacy: None,
},
Capability {
id: "settings.manage_privacy_analytics",
name: "Manage Privacy and Analytics",
+1
View File
@@ -1,6 +1,7 @@
//! Workspace layout and bootstrap files (CLI `init` and similar entrypoints).
pub mod ops;
pub mod rpc;
mod schemas;
pub use ops::*;
+15
View File
@@ -10,6 +10,21 @@ const BOOTSTRAP_FILES: [(&str, &str); 2] = [
("IDENTITY.md", include_str!("../agent/prompts/IDENTITY.md")),
];
/// Bundled default contents for a bootstrap workspace file, or `None` when the
/// name is not one of the files shipped in [`BOOTSTRAP_FILES`].
///
/// This is the single source of truth for both "which workspace files may be
/// edited from the Persona surface" and "what to restore on reset" — the
/// Persona Pack RPCs (`src/openhuman/workspace/rpc.rs`) treat membership here
/// as the editable allowlist so a caller can never read or clobber an
/// arbitrary path under the workspace.
pub fn bundled_default_contents(filename: &str) -> Option<&'static str> {
BOOTSTRAP_FILES
.iter()
.find(|(name, _)| *name == filename)
.map(|(_, contents)| *contents)
}
fn ensure_workspace_file(
workspace_dir: &Path,
filename: &str,
+342
View File
@@ -0,0 +1,342 @@
//! Read / edit / reset for the bundled persona prompt files (`SOUL.md`,
//! `IDENTITY.md`) that drive the agent's personality.
//!
//! Backs the Persona Pack settings surface (issue #2345). The editable set is
//! restricted to the bundled bootstrap files (see
//! [`crate::openhuman::workspace::ops::bundled_default_contents`]) so a caller
//! can never read or overwrite an arbitrary path under the workspace.
use std::io::Read;
use std::path::Path;
use serde::Serialize;
use crate::openhuman::workspace::ops::bundled_default_contents;
use crate::rpc::RpcOutcome;
/// Hard cap on the size accepted by [`write_workspace_file`] and tolerated by
/// [`read_workspace_file`]. `SOUL.md` / `IDENTITY.md` are prose prompts
/// measured in kilobytes; the cap bounds both a runaway paste from the UI and a
/// pathologically large file dropped on disk by another process (which would
/// otherwise be slurped fully into memory and shipped over RPC).
pub const MAX_WORKSPACE_FILE_BYTES: u64 = 256 * 1024;
/// A single editable persona file plus the metadata the settings UI needs to
/// render and round-trip it. The absolute on-disk path is deliberately **not**
/// part of this payload — the UI never needs it, and returning it would leak
/// host filesystem layout to every RPC caller.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorkspaceFile {
/// Allowlisted file name (e.g. `SOUL.md`).
pub filename: String,
/// Current effective contents.
pub contents: String,
/// `true` when `contents` came from the bundled default rather than a file
/// on disk — i.e. the workspace copy was missing (read) or has just been
/// restored (reset). Lets the UI show a "using default" affordance.
pub is_default: bool,
}
/// Resolve the bundled default for `filename`, rejecting any name that is not
/// part of the editable allowlist.
fn ensure_editable(filename: &str) -> Result<&'static str, String> {
bundled_default_contents(filename).ok_or_else(|| {
log::debug!("[workspace][rpc] rejected non-editable filename='{filename}'");
format!("'{filename}' is not an editable workspace file")
})
}
/// Read an editable persona file. When the workspace copy is missing (e.g. a
/// fresh install that has not run `init` yet) the bundled default is returned
/// with `is_default = true` so the editor always shows the effective prompt.
/// A file larger than [`MAX_WORKSPACE_FILE_BYTES`] is refused rather than
/// loaded into memory.
pub fn read_workspace_file(
workspace_dir: &Path,
filename: &str,
) -> Result<RpcOutcome<WorkspaceFile>, String> {
let default_contents = ensure_editable(filename)?;
let path = workspace_dir.join(filename);
// Open first, then enforce the cap on the *opened handle* via a bounded
// reader. A prior `metadata().len()` check would be TOCTOU-prone: another
// process could grow or swap the file between the stat and the read, so an
// oversized file could still be slurped fully into memory. Reading through
// `take(cap + 1)` caps the bytes we will ever hold regardless of races.
let file = match std::fs::File::open(&path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!(
"[workspace][rpc] read fallback-to-default file='{filename}' (missing on disk)"
);
return Ok(RpcOutcome::new(
WorkspaceFile {
filename: filename.to_string(),
contents: default_contents.to_string(),
is_default: true,
},
Vec::new(),
));
}
Err(e) => {
log::debug!(
"[workspace][rpc] read open-failed file='{filename}' path='{}': {e}",
path.display()
);
return Err(format!("failed to read {filename}: {e}"));
}
};
let mut buf = Vec::new();
file.take(MAX_WORKSPACE_FILE_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| {
log::debug!(
"[workspace][rpc] read failed file='{filename}' path='{}': {e}",
path.display()
);
format!("failed to read {filename}: {e}")
})?;
// Hitting the extra byte means the file exceeds the cap; refuse it.
if buf.len() as u64 > MAX_WORKSPACE_FILE_BYTES {
log::debug!(
"[workspace][rpc] read refused file='{filename}' (over cap={MAX_WORKSPACE_FILE_BYTES})"
);
return Err(format!(
"{filename} is too large to edit (over the {MAX_WORKSPACE_FILE_BYTES}-byte limit)"
));
}
let contents = String::from_utf8(buf).map_err(|e| {
log::debug!("[workspace][rpc] read rejected non-utf8 file='{filename}': {e}");
format!("failed to read {filename}: contents are not valid UTF-8")
})?;
log::debug!(
"[workspace][rpc] read ok file='{filename}' bytes={}",
contents.len()
);
Ok(RpcOutcome::new(
WorkspaceFile {
filename: filename.to_string(),
contents,
is_default: false,
},
Vec::new(),
))
}
/// Overwrite an editable persona file with user-supplied contents. Rejects
/// non-allowlisted names and anything over [`MAX_WORKSPACE_FILE_BYTES`]; the
/// workspace directory is created if it does not yet exist.
pub fn write_workspace_file(
workspace_dir: &Path,
filename: &str,
contents: &str,
) -> Result<RpcOutcome<WorkspaceFile>, String> {
ensure_editable(filename)?;
if contents.len() as u64 > MAX_WORKSPACE_FILE_BYTES {
log::debug!(
"[workspace][rpc] write refused file='{filename}' bytes={} cap={MAX_WORKSPACE_FILE_BYTES}",
contents.len()
);
return Err(format!(
"contents for {filename} exceed the {MAX_WORKSPACE_FILE_BYTES}-byte limit"
));
}
let path = workspace_dir.join(filename);
std::fs::create_dir_all(workspace_dir).map_err(|e| {
log::debug!(
"[workspace][rpc] write mkdir-failed dir='{}': {e}",
workspace_dir.display()
);
format!("failed to prepare the workspace directory: {e}")
})?;
std::fs::write(&path, contents).map_err(|e| {
log::debug!(
"[workspace][rpc] write failed file='{filename}' path='{}': {e}",
path.display()
);
format!("failed to write {filename}: {e}")
})?;
log::debug!(
"[workspace][rpc] write ok file='{filename}' bytes={}",
contents.len()
);
Ok(RpcOutcome::new(
WorkspaceFile {
filename: filename.to_string(),
contents: contents.to_string(),
is_default: false,
},
Vec::new(),
))
}
/// Restore an editable persona file to its bundled default and return the
/// restored contents.
pub fn reset_workspace_file(
workspace_dir: &Path,
filename: &str,
) -> Result<RpcOutcome<WorkspaceFile>, String> {
let default_contents = ensure_editable(filename)?;
let path = workspace_dir.join(filename);
std::fs::create_dir_all(workspace_dir).map_err(|e| {
log::debug!(
"[workspace][rpc] reset mkdir-failed dir='{}': {e}",
workspace_dir.display()
);
format!("failed to prepare the workspace directory: {e}")
})?;
std::fs::write(&path, default_contents).map_err(|e| {
log::debug!(
"[workspace][rpc] reset failed file='{filename}' path='{}': {e}",
path.display()
);
format!("failed to reset {filename}: {e}")
})?;
log::debug!("[workspace][rpc] reset ok file='{filename}' (restored bundled default)");
Ok(RpcOutcome::new(
WorkspaceFile {
filename: filename.to_string(),
contents: default_contents.to_string(),
is_default: true,
},
Vec::new(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn read_returns_bundled_default_when_file_missing() {
let tmp = tempdir().unwrap();
let outcome = read_workspace_file(tmp.path(), "SOUL.md").expect("read should succeed");
let file = outcome.value;
assert!(file.is_default, "missing file should report the default");
assert!(!file.contents.trim().is_empty());
assert_eq!(file.filename, "SOUL.md");
assert_eq!(
file.contents,
bundled_default_contents("SOUL.md").unwrap(),
"default read must match the bundled prompt"
);
}
#[test]
fn read_returns_on_disk_contents_when_present() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join("SOUL.md"), "custom soul").unwrap();
let file = read_workspace_file(tmp.path(), "SOUL.md")
.expect("read ok")
.value;
assert!(!file.is_default);
assert_eq!(file.contents, "custom soul");
}
#[test]
fn read_refuses_oversize_file_on_disk() {
let tmp = tempdir().unwrap();
let huge = "a".repeat((MAX_WORKSPACE_FILE_BYTES + 1) as usize);
std::fs::write(tmp.path().join("SOUL.md"), &huge).unwrap();
let err = read_workspace_file(tmp.path(), "SOUL.md").unwrap_err();
assert!(err.contains("too large"), "unexpected error: {err}");
}
#[test]
fn read_accepts_file_exactly_at_the_size_limit() {
let tmp = tempdir().unwrap();
let at_limit = "a".repeat(MAX_WORKSPACE_FILE_BYTES as usize);
std::fs::write(tmp.path().join("SOUL.md"), &at_limit).unwrap();
let file = read_workspace_file(tmp.path(), "SOUL.md")
.expect("exactly-at-limit read should succeed")
.value;
assert_eq!(file.contents.len(), MAX_WORKSPACE_FILE_BYTES as usize);
assert!(!file.is_default);
}
#[test]
fn read_rejects_non_utf8_file() {
let tmp = tempdir().unwrap();
// A lone 0xFF byte is never valid UTF-8.
std::fs::write(tmp.path().join("SOUL.md"), [0xff_u8, 0xfe, 0xfd]).unwrap();
let err = read_workspace_file(tmp.path(), "SOUL.md").unwrap_err();
assert!(err.contains("UTF-8"), "unexpected error: {err}");
}
#[test]
fn write_then_read_round_trips() {
let tmp = tempdir().unwrap();
let written = write_workspace_file(tmp.path(), "SOUL.md", "You are calm and concise.")
.expect("write ok")
.value;
assert!(!written.is_default);
assert_eq!(written.contents, "You are calm and concise.");
let read = read_workspace_file(tmp.path(), "SOUL.md")
.expect("read ok")
.value;
assert_eq!(read.contents, "You are calm and concise.");
assert!(!read.is_default);
}
#[test]
fn write_creates_workspace_dir_if_missing() {
let tmp = tempdir().unwrap();
let nested = tmp.path().join("does/not/exist/yet");
let written = write_workspace_file(&nested, "IDENTITY.md", "id")
.expect("write should create the dir")
.value;
assert_eq!(written.contents, "id");
assert!(nested.join("IDENTITY.md").is_file());
}
#[test]
fn reset_restores_bundled_default() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join("SOUL.md"), "corrupted").unwrap();
let reset = reset_workspace_file(tmp.path(), "SOUL.md")
.expect("reset ok")
.value;
assert!(reset.is_default);
assert_eq!(reset.contents, bundled_default_contents("SOUL.md").unwrap());
let on_disk = std::fs::read_to_string(tmp.path().join("SOUL.md")).unwrap();
assert_eq!(on_disk, bundled_default_contents("SOUL.md").unwrap());
}
#[test]
fn non_allowlisted_filename_is_rejected_for_every_op() {
let tmp = tempdir().unwrap();
for name in ["secrets.txt", "../escape.md", "MEMORY.md", "soul.md"] {
assert!(read_workspace_file(tmp.path(), name).is_err());
assert!(write_workspace_file(tmp.path(), name, "x").is_err());
assert!(reset_workspace_file(tmp.path(), name).is_err());
}
// The rejection must not have written anything to disk.
assert!(!tmp.path().join("MEMORY.md").exists());
}
#[test]
fn write_rejects_oversize_contents() {
let tmp = tempdir().unwrap();
let huge = "a".repeat((MAX_WORKSPACE_FILE_BYTES + 1) as usize);
let err = write_workspace_file(tmp.path(), "SOUL.md", &huge).unwrap_err();
assert!(err.contains("limit"), "unexpected error: {err}");
assert!(
!tmp.path().join("SOUL.md").exists(),
"oversize write must not touch disk"
);
}
#[test]
fn write_accepts_contents_at_the_size_limit() {
let tmp = tempdir().unwrap();
let at_limit = "a".repeat(MAX_WORKSPACE_FILE_BYTES as usize);
let written = write_workspace_file(tmp.path(), "SOUL.md", &at_limit)
.expect("exactly-at-limit write should succeed")
.value;
assert_eq!(written.contents.len(), MAX_WORKSPACE_FILE_BYTES as usize);
}
}
+208 -4
View File
@@ -1,10 +1,214 @@
use crate::core::all::RegisteredController;
use crate::core::ControllerSchema;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::workspace::rpc as workspace_rpc;
use crate::rpc::RpcOutcome;
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
Vec::new()
vec![
schemas("file_read"),
schemas("file_write"),
schemas("file_reset"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
Vec::new()
vec![
RegisteredController {
schema: schemas("file_read"),
handler: handle_file_read,
},
RegisteredController {
schema: schemas("file_write"),
handler: handle_file_write,
},
RegisteredController {
schema: schemas("file_reset"),
handler: handle_file_reset,
},
]
}
fn filename_input() -> FieldSchema {
FieldSchema {
name: "filename",
ty: TypeSchema::String,
comment: "Editable persona file name; must be SOUL.md or IDENTITY.md.",
required: true,
}
}
fn workspace_file_outputs() -> Vec<FieldSchema> {
vec![
FieldSchema {
name: "filename",
ty: TypeSchema::String,
comment: "The resolved persona file name.",
required: true,
},
FieldSchema {
name: "contents",
ty: TypeSchema::String,
comment: "Current effective contents of the file.",
required: true,
},
FieldSchema {
name: "is_default",
ty: TypeSchema::Bool,
comment: "True when the contents are the bundled default (file missing on read, or just reset).",
required: true,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"file_read" => ControllerSchema {
namespace: "workspace",
function: "file_read",
description: "Read an editable persona file (SOUL.md / IDENTITY.md), falling back to the bundled default when the workspace copy is missing.",
inputs: vec![filename_input()],
outputs: workspace_file_outputs(),
},
"file_write" => ControllerSchema {
namespace: "workspace",
function: "file_write",
description: "Overwrite an editable persona file with new contents.",
inputs: vec![
filename_input(),
FieldSchema {
name: "contents",
ty: TypeSchema::String,
comment: "New file contents (size-capped server-side).",
required: true,
},
],
outputs: workspace_file_outputs(),
},
"file_reset" => ControllerSchema {
namespace: "workspace",
function: "file_reset",
description: "Restore an editable persona file to its bundled default.",
inputs: vec![filename_input()],
outputs: workspace_file_outputs(),
},
_other => ControllerSchema {
namespace: "workspace",
function: "unknown",
description: "Unknown workspace controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_file_read(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let filename = read_required::<String>(&params, "filename")?;
let filename = filename.trim();
log::debug!("[workspace][rpc] handle_file_read filename='{filename}'");
let result = workspace_rpc::read_workspace_file(&config.workspace_dir, filename);
if let Err(ref e) = result {
log::debug!("[workspace][rpc] handle_file_read error filename='{filename}': {e}");
}
to_json(result?)
})
}
fn handle_file_write(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let filename = read_required::<String>(&params, "filename")?;
let contents = read_required::<String>(&params, "contents")?;
let filename = filename.trim();
log::debug!(
"[workspace][rpc] handle_file_write filename='{filename}' bytes={}",
contents.len()
);
let result =
workspace_rpc::write_workspace_file(&config.workspace_dir, filename, &contents);
if let Err(ref e) = result {
log::debug!("[workspace][rpc] handle_file_write error filename='{filename}': {e}");
}
to_json(result?)
})
}
fn handle_file_reset(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let filename = read_required::<String>(&params, "filename")?;
let filename = filename.trim();
log::debug!("[workspace][rpc] handle_file_reset filename='{filename}'");
let result = workspace_rpc::reset_workspace_file(&config.workspace_dir, filename);
if let Err(ref e) = result {
log::debug!("[workspace][rpc] handle_file_reset error filename='{filename}': {e}");
}
to_json(result?)
})
}
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
let value = params
.get(key)
.cloned()
.ok_or_else(|| format!("missing required param '{key}'"))?;
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_schemas_use_workspace_namespace() {
let all = all_controller_schemas();
assert_eq!(all.len(), 3);
for schema in &all {
assert_eq!(schema.namespace, "workspace");
}
}
#[test]
fn registered_controllers_expose_expected_rpc_methods() {
let methods: Vec<String> = all_registered_controllers()
.iter()
.map(|c| c.rpc_method_name())
.collect();
assert!(methods.contains(&"openhuman.workspace_file_read".to_string()));
assert!(methods.contains(&"openhuman.workspace_file_write".to_string()));
assert!(methods.contains(&"openhuman.workspace_file_reset".to_string()));
}
#[test]
fn file_write_schema_requires_filename_and_contents() {
let schema = schemas("file_write");
let input_names: Vec<&str> = schema.inputs.iter().map(|f| f.name).collect();
assert!(input_names.contains(&"filename"));
assert!(input_names.contains(&"contents"));
}
#[test]
fn unknown_function_yields_unknown_schema() {
let schema = schemas("nope");
assert_eq!(schema.function, "unknown");
}
}