mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Chen Qian <cq@Chens-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Chen Qian
Claude Opus 4.7
parent
95aeab500e
commit
48548a223b
@@ -16,6 +16,7 @@ export type SettingsRoute =
|
||||
| 'team-members'
|
||||
| 'team-invites'
|
||||
| 'developer-options'
|
||||
| 'autonomy'
|
||||
| 'ai'
|
||||
| 'llm'
|
||||
| 'voice'
|
||||
@@ -92,6 +93,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/privacy')) return 'privacy';
|
||||
if (path.includes('/settings/billing')) return 'billing';
|
||||
if (path.includes('/settings/developer-options')) return 'developer-options';
|
||||
if (path.includes('/settings/autonomy')) return 'autonomy';
|
||||
if (path.includes('/settings/llm')) return 'llm';
|
||||
if (path.includes('/settings/ai')) return 'ai';
|
||||
if (path.includes('/settings/local-model-debug')) return 'local-model-debug';
|
||||
@@ -222,6 +224,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'composio-routing':
|
||||
case 'notification-routing':
|
||||
case 'mcp-server':
|
||||
case 'autonomy':
|
||||
return [settingsCrumb, developerCrumb];
|
||||
|
||||
// Developer options section page
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
openhumanGetAutonomySettings,
|
||||
openhumanUpdateAutonomySettings,
|
||||
} from '../../../utils/tauriCommands/config';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '20 (default)', value: 20 },
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '500', value: 500 },
|
||||
{ label: '1000', value: 1000 },
|
||||
];
|
||||
|
||||
const MIN = 1;
|
||||
const MAX = 10_000;
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'saving' }
|
||||
| { kind: 'saved' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
/**
|
||||
* Settings panel under Developer Options for editing the agent's
|
||||
* max_actions_per_hour rate-limit. Loads the current value via
|
||||
* openhumanGetAutonomySettings on mount; saving writes through
|
||||
* openhumanUpdateAutonomySettings and persists to the user's config.toml.
|
||||
* New value applies to the next agent session.
|
||||
*/
|
||||
const AutonomyPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [committed, setCommitted] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState<string>('');
|
||||
const [status, setStatus] = useState<Status>({ kind: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await openhumanGetAutonomySettings();
|
||||
if (cancelled) return;
|
||||
const value = res.result.max_actions_per_hour;
|
||||
setCommitted(value);
|
||||
setDraft(String(value));
|
||||
setStatus({ kind: 'idle' });
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const trimmed = draft.trim();
|
||||
const parsed = Number(trimmed);
|
||||
const isValid =
|
||||
/^\d+$/.test(trimmed) && Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX;
|
||||
const isChanged = committed !== null && parsed !== committed;
|
||||
const canSave = isValid && isChanged && status.kind !== 'saving';
|
||||
|
||||
const applyPreset = (value: number) => {
|
||||
setDraft(String(value));
|
||||
if (status.kind === 'saved' || status.kind === 'error') {
|
||||
setStatus({ kind: 'idle' });
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (!canSave) return;
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
await openhumanUpdateAutonomySettings({ max_actions_per_hour: parsed });
|
||||
setCommitted(parsed);
|
||||
setStatus({ kind: 'saved' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Revert UI to last committed value, then surface the error.
|
||||
if (committed !== null) setDraft(String(committed));
|
||||
setStatus({ kind: 'error', message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Agent autonomy"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<section className="px-4 py-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||
<label
|
||||
htmlFor="autonomy-max-actions"
|
||||
className="block text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Max actions per hour
|
||||
</label>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400 mt-1">
|
||||
Maximum tool actions an agent can run per rolling hour. New value applies to your next
|
||||
chat. Cron jobs and channel listeners keep their current limit until you restart
|
||||
OpenHuman.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
id="autonomy-max-actions"
|
||||
type="number"
|
||||
min={MIN}
|
||||
max={MAX}
|
||||
step={1}
|
||||
value={draft}
|
||||
onChange={e => {
|
||||
setDraft(e.target.value);
|
||||
if (status.kind === 'saved' || status.kind === 'error') {
|
||||
setStatus({ kind: 'idle' });
|
||||
}
|
||||
}}
|
||||
disabled={status.kind === 'loading' || status.kind === 'saving'}
|
||||
className="w-32 px-3 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
className="px-3 py-1.5 rounded-md bg-primary-600 hover:bg-primary-500 disabled:opacity-50 text-white text-xs font-medium transition-colors">
|
||||
{status.kind === 'saving' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => applyPreset(p.value)}
|
||||
className="px-2 py-1 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800">
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="mt-3 text-xs min-h-[1rem]">
|
||||
{!isValid && draft.trim() !== '' && (
|
||||
<span className="text-coral-600 dark:text-coral-300">
|
||||
Must be an integer between {MIN} and {MAX.toLocaleString()}.
|
||||
</span>
|
||||
)}
|
||||
{status.kind === 'saved' && (
|
||||
<span className="text-sage-700 dark:text-sage-300">Saved.</span>
|
||||
)}
|
||||
{status.kind === 'error' && (
|
||||
<span className="text-coral-600 dark:text-coral-300">Failed: {status.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutonomyPanel;
|
||||
@@ -253,6 +253,22 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'autonomy',
|
||||
titleKey: 'settings.developerMenu.autonomy.title',
|
||||
descriptionKey: 'settings.developerMenu.autonomy.desc',
|
||||
route: 'autonomy',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const CoreModeBadge = () => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import {
|
||||
openhumanGetAutonomySettings,
|
||||
openhumanUpdateAutonomySettings,
|
||||
} from '../../../../utils/tauriCommands/config';
|
||||
import AutonomyPanel from '../AutonomyPanel';
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: vi.fn(),
|
||||
navigateToSettings: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/tauriCommands/config', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../utils/tauriCommands/config')>(
|
||||
'../../../../utils/tauriCommands/config'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
openhumanGetAutonomySettings: vi.fn(),
|
||||
openhumanUpdateAutonomySettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGet = vi.mocked(openhumanGetAutonomySettings);
|
||||
const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings);
|
||||
|
||||
describe('AutonomyPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
});
|
||||
|
||||
test('loads the current value on mount', async () => {
|
||||
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] });
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = (await screen.findByLabelText(/Max actions per hour/i)) as HTMLInputElement;
|
||||
await waitFor(() => expect(input).toHaveValue(250));
|
||||
});
|
||||
|
||||
test('Save is disabled until the value changes', async () => {
|
||||
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const saveBtn = await screen.findByRole('button', { name: /^Save$/ });
|
||||
expect(saveBtn).toBeDisabled();
|
||||
|
||||
const input = await screen.findByDisplayValue('20');
|
||||
fireEvent.change(input, { target: { value: '100' } });
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('Save invokes the wrapper and shows confirmation', async () => {
|
||||
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
|
||||
mockUpdate.mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
});
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = await screen.findByDisplayValue('20');
|
||||
fireEvent.change(input, { target: { value: '300' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 }));
|
||||
await screen.findByText(/Saved\./i);
|
||||
});
|
||||
|
||||
test('shows inline validation when the value is out of range', async () => {
|
||||
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = await screen.findByDisplayValue('20');
|
||||
fireEvent.change(input, { target: { value: '0' } });
|
||||
await screen.findByText(/Must be an integer between 1 and 10,000/i);
|
||||
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
// Note: '12abc' is omitted because <input type="number"> filters non-numeric
|
||||
// characters before React sees the change event — there's no way the panel
|
||||
// can receive that input through normal UI flow.
|
||||
test.each(['1.5', '1e2', '-5', '0.0'])('rejects non-integer input %s', async value => {
|
||||
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = await screen.findByDisplayValue('20');
|
||||
fireEvent.change(input, { target: { value } });
|
||||
await screen.findByText(/Must be an integer between 1 and 10,000/i);
|
||||
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('surfaces RPC errors and reverts to the last committed value', async () => {
|
||||
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 50 }, logs: [] });
|
||||
mockUpdate.mockRejectedValue(new Error('disk full'));
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = (await screen.findByDisplayValue('50')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '500' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await screen.findByText(/Failed: disk full/);
|
||||
// Reverted to last committed value.
|
||||
expect(input).toHaveValue(50);
|
||||
});
|
||||
});
|
||||
@@ -479,6 +479,8 @@ const ar5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'استقلالية الوكيل',
|
||||
'settings.developerMenu.autonomy.desc': 'حدود معدل إجراءات الأدوات وعتبات الأمان',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -485,6 +485,8 @@ const bn5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'এজেন্ট স্বায়ত্তশাসন',
|
||||
'settings.developerMenu.autonomy.desc': 'টুল অ্যাকশনের রেট সীমা এবং নিরাপত্তা থ্রেশহোল্ড',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -211,6 +211,9 @@ const de5: TranslationMap = {
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc':
|
||||
'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Agent-Autonomie',
|
||||
'settings.developerMenu.autonomy.desc':
|
||||
'Aktionsraten-Limits und Sicherheitsschwellen für Werkzeuge',
|
||||
'settings.developerMenu.integrationTriggers.title': 'Integrationsauslöser',
|
||||
'settings.developerMenu.integrationTriggers.desc':
|
||||
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
|
||||
|
||||
@@ -485,6 +485,8 @@ const en5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Agent autonomy',
|
||||
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -490,6 +490,9 @@ const es5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Autonomía del agente',
|
||||
'settings.developerMenu.autonomy.desc':
|
||||
'Límites de frecuencia de acciones de herramientas y umbrales de seguridad',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -494,6 +494,9 @@ const fr5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Autonomie de l’agent',
|
||||
'settings.developerMenu.autonomy.desc':
|
||||
'Limites de fréquence des actions des outils et seuils de sécurité',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -487,6 +487,8 @@ const hi5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'एजेंट स्वायत्तता',
|
||||
'settings.developerMenu.autonomy.desc': 'टूल क्रिया दर सीमाएँ और सुरक्षा सीमाएँ',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -488,6 +488,8 @@ const id5: TranslationMap = {
|
||||
'settings.developerMenu.mcpServer.title': 'Server MCP',
|
||||
'settings.developerMenu.mcpServer.desc':
|
||||
'Konfigurasikan klien MCP eksternal untuk terhubung ke OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Otonomi agen',
|
||||
'settings.developerMenu.autonomy.desc': 'Batas laju aksi alat dan ambang keamanan',
|
||||
'settings.mcpServer.title': 'Server MCP',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Alat yang tersedia',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -491,6 +491,9 @@ const it5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Autonomia agente',
|
||||
'settings.developerMenu.autonomy.desc':
|
||||
'Limiti di frequenza delle azioni degli strumenti e soglie di sicurezza',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -448,6 +448,8 @@ const ko5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': '에이전트 자율성',
|
||||
'settings.developerMenu.autonomy.desc': '도구 작업 속도 제한 및 안전 임계값',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -491,6 +491,9 @@ const pt5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Autonomia do agente',
|
||||
'settings.developerMenu.autonomy.desc':
|
||||
'Limites de taxa de ações de ferramentas e limites de segurança',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -488,6 +488,9 @@ const ru5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Автономия агента',
|
||||
'settings.developerMenu.autonomy.desc':
|
||||
'Ограничения частоты действий инструментов и пороги безопасности',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -460,6 +460,8 @@ const zhCN5: TranslationMap = {
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP 服务器',
|
||||
'settings.developerMenu.mcpServer.desc': '配置外部 MCP 客户端以连接到 OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': '智能体自主权',
|
||||
'settings.developerMenu.autonomy.desc': '工具操作速率限制和安全阈值',
|
||||
'settings.mcpServer.title': 'MCP 服务器',
|
||||
'settings.mcpServer.toolsSectionTitle': '可用工具',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -1963,6 +1963,8 @@ const en: TranslationMap = {
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.developerMenu.autonomy.title': 'Agent autonomy',
|
||||
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
|
||||
@@ -7,6 +7,7 @@ import AIPanel from '../components/settings/panels/AIPanel';
|
||||
import AppearancePanel from '../components/settings/panels/AppearancePanel';
|
||||
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
|
||||
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
|
||||
import AutonomyPanel from '../components/settings/panels/AutonomyPanel';
|
||||
import BillingPanel from '../components/settings/panels/BillingPanel';
|
||||
import CompanionPanel from '../components/settings/panels/CompanionPanel';
|
||||
import ComposioPanel from '../components/settings/panels/ComposioPanel';
|
||||
@@ -353,6 +354,7 @@ const Settings = () => {
|
||||
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
|
||||
{/* Developer Options */}
|
||||
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
|
||||
<Route path="autonomy" element={wrapSettingsPage(<AutonomyPanel />)} />
|
||||
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
|
||||
<Route
|
||||
path="notification-routing"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export const CORE_RPC_METHODS = {
|
||||
configGet: 'openhuman.config_get',
|
||||
configGetAnalyticsSettings: 'openhuman.config_get_analytics_settings',
|
||||
configGetAutonomySettings: 'openhuman.config_get_autonomy_settings',
|
||||
configGetComposioTriggerSettings: 'openhuman.config_get_composio_trigger_settings',
|
||||
configGetRuntimeFlags: 'openhuman.config_get_runtime_flags',
|
||||
configSetBrowserAllowAll: 'openhuman.config_set_browser_allow_all',
|
||||
configUpdateAnalyticsSettings: 'openhuman.config_update_analytics_settings',
|
||||
configUpdateAutonomySettings: 'openhuman.config_update_autonomy_settings',
|
||||
configUpdateBrowserSettings: 'openhuman.config_update_browser_settings',
|
||||
configUpdateComposioTriggerSettings: 'openhuman.config_update_composio_trigger_settings',
|
||||
configUpdateLocalAiSettings: 'openhuman.config_update_local_ai_settings',
|
||||
|
||||
@@ -9,17 +9,21 @@ vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
describe('tauriCommands/config', () => {
|
||||
const mockIsTauri = isTauri as Mock;
|
||||
const mockCallCoreRpc = callCoreRpc as Mock;
|
||||
let openhumanGetAutonomySettings: typeof import('./config').openhumanGetAutonomySettings;
|
||||
let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings;
|
||||
let openhumanUpdateAutonomySettings: typeof import('./config').openhumanUpdateAutonomySettings;
|
||||
let openhumanUpdateLocalAiSettings: typeof import('./config').openhumanUpdateLocalAiSettings;
|
||||
let openhumanUpdateMeetSettings: typeof import('./config').openhumanUpdateMeetSettings;
|
||||
let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
const actual = await vi.importActual<typeof import('./config')>('./config');
|
||||
openhumanGetAutonomySettings = actual.openhumanGetAutonomySettings;
|
||||
openhumanGetMeetSettings = actual.openhumanGetMeetSettings;
|
||||
openhumanUpdateAutonomySettings = actual.openhumanUpdateAutonomySettings;
|
||||
openhumanUpdateLocalAiSettings = actual.openhumanUpdateLocalAiSettings;
|
||||
openhumanUpdateMeetSettings = actual.openhumanUpdateMeetSettings;
|
||||
openhumanGetMeetSettings = actual.openhumanGetMeetSettings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,6 +101,45 @@ describe('tauriCommands/config', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanUpdateAutonomySettings', () => {
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 })).rejects.toThrow(
|
||||
'Not running in Tauri'
|
||||
);
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('forwards the patch to openhuman.config_update_autonomy_settings', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
});
|
||||
await openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.config_update_autonomy_settings',
|
||||
params: { max_actions_per_hour: 100 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanGetAutonomySettings', () => {
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(openhumanGetAutonomySettings()).rejects.toThrow('Not running in Tauri');
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reads via openhuman.config_get_autonomy_settings', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] });
|
||||
const out = await openhumanGetAutonomySettings();
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.config_get_autonomy_settings',
|
||||
});
|
||||
expect(out.result.max_actions_per_hour).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanUpdateComposioTriggerSettings', () => {
|
||||
let openhumanUpdateComposioTriggerSettings: typeof import('./config').openhumanUpdateComposioTriggerSettings;
|
||||
|
||||
|
||||
@@ -355,6 +355,38 @@ export async function openhumanGetMeetSettings(): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the agent autonomy policy settings (currently just the per-hour tool
|
||||
* action ceiling). Persists to the user's `config.toml`. Takes effect on the
|
||||
* next agent session — running sessions / cron jobs / channel listeners keep
|
||||
* the limit they were started with until core restart.
|
||||
*/
|
||||
export async function openhumanUpdateAutonomySettings(update: {
|
||||
max_actions_per_hour?: number;
|
||||
}): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
|
||||
method: CORE_RPC_METHODS.configUpdateAutonomySettings,
|
||||
params: update,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current agent autonomy policy settings from the loaded config.
|
||||
*/
|
||||
export async function openhumanGetAutonomySettings(): Promise<
|
||||
CommandResponse<{ max_actions_per_hour: number }>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<{ max_actions_per_hour: number }>>({
|
||||
method: CORE_RPC_METHODS.configGetAutonomySettings,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ComposioTriggerSettingsUpdate {
|
||||
triage_disabled?: boolean | null;
|
||||
triage_disabled_toolkits?: string[] | null;
|
||||
|
||||
@@ -98,6 +98,32 @@ describe('Settings - Advanced Config', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('persists autonomy max_actions_per_hour through core RPC', async function () {
|
||||
this.timeout(60_000);
|
||||
const before = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {});
|
||||
expect(before.ok).toBe(true);
|
||||
const current = before.result?.result?.max_actions_per_hour ?? 20;
|
||||
// Pick a value different from the current one so the save actually mutates state.
|
||||
const target = current === 250 ? 251 : 250;
|
||||
|
||||
await navigateViaHash('/settings/autonomy');
|
||||
await waitForText('Agent autonomy', 15_000);
|
||||
|
||||
const input = await browser.$('#autonomy-max-actions');
|
||||
await input.waitForExist({ timeout: 10_000 });
|
||||
await input.setValue(String(target));
|
||||
await clickText('Save', 10_000);
|
||||
await waitForText('Saved.', 10_000);
|
||||
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const after = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {});
|
||||
return after.ok && after.result?.result?.max_actions_per_hour === target;
|
||||
},
|
||||
{ timeout: 15_000, interval: 500, timeoutMsg: 'autonomy setting did not persist' }
|
||||
);
|
||||
});
|
||||
|
||||
it('switches composio routing mode to direct and can return to backend mode', async function () {
|
||||
this.timeout(60_000);
|
||||
await navigateViaHash('/settings/composio-routing');
|
||||
|
||||
@@ -374,6 +374,11 @@ pub struct MeetSettingsPatch {
|
||||
pub auto_orchestrator_handoff: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AutonomySettingsPatch {
|
||||
pub max_actions_per_hour: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocalAiSettingsPatch {
|
||||
pub runtime_enabled: Option<bool>,
|
||||
@@ -813,6 +818,39 @@ pub async fn load_and_apply_meet_settings(
|
||||
apply_meet_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Updates the autonomy policy settings in the configuration.
|
||||
/// Validation: 1 <= max_actions_per_hour <= 10_000.
|
||||
pub async fn apply_autonomy_settings(
|
||||
config: &mut Config,
|
||||
update: AutonomySettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
if let Some(v) = update.max_actions_per_hour {
|
||||
if v == 0 || v > 10_000 {
|
||||
return Err(format!(
|
||||
"max_actions_per_hour must be between 1 and 10000 (got {v})"
|
||||
));
|
||||
}
|
||||
config.autonomy.max_actions_per_hour = v;
|
||||
}
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
snapshot,
|
||||
vec![format!(
|
||||
"autonomy settings saved to {}",
|
||||
config.config_path.display()
|
||||
)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Loads the configuration, applies autonomy settings updates, and saves it.
|
||||
pub async fn load_and_apply_autonomy_settings(
|
||||
update: AutonomySettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
apply_autonomy_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Loads the configuration, applies browser settings updates, and saves it.
|
||||
pub async fn load_and_apply_browser_settings(
|
||||
update: BrowserSettingsPatch,
|
||||
|
||||
@@ -1128,3 +1128,102 @@ async fn apply_screen_intelligence_settings_clamps_baseline_fps() {
|
||||
.expect("low clamp");
|
||||
assert!((cfg.screen_intelligence.baseline_fps - 0.2).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// ── apply_autonomy_settings ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_autonomy_settings_persists_max_actions_per_hour() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
let outcome = apply_autonomy_settings(
|
||||
&mut cfg,
|
||||
AutonomySettingsPatch {
|
||||
max_actions_per_hour: Some(200),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
assert_eq!(cfg.autonomy.max_actions_per_hour, 200);
|
||||
// Snapshot returned so the caller can echo the saved state.
|
||||
assert!(outcome.value.get("config").is_some());
|
||||
// Round-trip from disk: reload the saved TOML and confirm.
|
||||
let on_disk = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
|
||||
assert!(
|
||||
on_disk.contains("max_actions_per_hour = 200"),
|
||||
"expected TOML to contain max_actions_per_hour = 200, got:\n{on_disk}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_autonomy_settings_no_op_when_patch_empty() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
let prior = cfg.autonomy.max_actions_per_hour;
|
||||
let _ = apply_autonomy_settings(
|
||||
&mut cfg,
|
||||
AutonomySettingsPatch {
|
||||
max_actions_per_hour: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply noop");
|
||||
assert_eq!(cfg.autonomy.max_actions_per_hour, prior);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_autonomy_settings_rejects_zero() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
let err = apply_autonomy_settings(
|
||||
&mut cfg,
|
||||
AutonomySettingsPatch {
|
||||
max_actions_per_hour: Some(0),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("between 1 and 10000"),
|
||||
"expected validation error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_autonomy_settings_rejects_above_cap() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
let err = apply_autonomy_settings(
|
||||
&mut cfg,
|
||||
AutonomySettingsPatch {
|
||||
max_actions_per_hour: Some(10_001),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("between 1 and 10000"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_and_apply_autonomy_settings_roundtrip() {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
}
|
||||
|
||||
let patch = AutonomySettingsPatch {
|
||||
max_actions_per_hour: Some(500),
|
||||
};
|
||||
let outcome = load_and_apply_autonomy_settings(patch)
|
||||
.await
|
||||
.expect("apply");
|
||||
assert!(outcome.value.get("config").is_some());
|
||||
|
||||
// Reload from scratch and confirm the saved value sticks.
|
||||
let reloaded = load_config_with_timeout().await.expect("reload");
|
||||
assert_eq!(reloaded.autonomy.max_actions_per_hour, 500);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_WORKSPACE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ struct MeetSettingsUpdate {
|
||||
auto_orchestrator_handoff: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AutonomySettingsUpdate {
|
||||
max_actions_per_hour: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalAiSettingsUpdate {
|
||||
runtime_enabled: Option<bool>,
|
||||
@@ -206,6 +211,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("get_analytics_settings"),
|
||||
schemas("update_meet_settings"),
|
||||
schemas("get_meet_settings"),
|
||||
schemas("update_autonomy_settings"),
|
||||
schemas("get_autonomy_settings"),
|
||||
schemas("agent_server_status"),
|
||||
schemas("reset_local_data"),
|
||||
schemas("get_data_paths"),
|
||||
@@ -290,6 +297,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("get_meet_settings"),
|
||||
handler: handle_get_meet_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("update_autonomy_settings"),
|
||||
handler: handle_update_autonomy_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_autonomy_settings"),
|
||||
handler: handle_get_autonomy_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("agent_server_status"),
|
||||
handler: handle_agent_server_status,
|
||||
@@ -692,6 +707,31 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"update_autonomy_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_autonomy_settings",
|
||||
description:
|
||||
"Update agent autonomy policy settings (currently the per-hour tool action ceiling).",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "max_actions_per_hour",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum tool actions an agent may run per rolling hour (1-10000).",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
"get_autonomy_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_autonomy_settings",
|
||||
description: "Read current agent autonomy policy settings.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "max_actions_per_hour",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Current maximum tool actions per rolling hour.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"agent_server_status" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "agent_server_status",
|
||||
@@ -1195,6 +1235,60 @@ fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_autonomy_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[config][rpc] update_autonomy_settings enter");
|
||||
let update = match deserialize_params::<AutonomySettingsUpdate>(params) {
|
||||
Ok(u) => u,
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_autonomy_settings invalid params: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
log::debug!(
|
||||
"[config][rpc] update_autonomy_settings patch max_actions_per_hour={:?}",
|
||||
update.max_actions_per_hour
|
||||
);
|
||||
let patch = config_rpc::AutonomySettingsPatch {
|
||||
max_actions_per_hour: update.max_actions_per_hour,
|
||||
};
|
||||
match config_rpc::load_and_apply_autonomy_settings(patch).await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] update_autonomy_settings ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_autonomy_settings failed: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_autonomy_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
log::debug!("[config][rpc] get_autonomy_settings enter");
|
||||
let config = match config_rpc::load_config_with_timeout().await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] get_autonomy_settings load failed: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let max_actions_per_hour = config.autonomy.max_actions_per_hour;
|
||||
log::debug!(
|
||||
"[config][rpc] get_autonomy_settings ok max_actions_per_hour={max_actions_per_hour}"
|
||||
);
|
||||
let result = serde_json::json!({
|
||||
"max_actions_per_hour": max_actions_per_hour,
|
||||
});
|
||||
to_json(RpcOutcome::new(
|
||||
result,
|
||||
vec!["autonomy settings read".to_string()],
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_agent_server_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::agent_server_status()) })
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ fn every_registered_key_resolves_to_non_unknown_schema() {
|
||||
"get_analytics_settings",
|
||||
"update_meet_settings",
|
||||
"get_meet_settings",
|
||||
"update_autonomy_settings",
|
||||
"get_autonomy_settings",
|
||||
"agent_server_status",
|
||||
"reset_local_data",
|
||||
"get_onboarding_completed",
|
||||
@@ -217,3 +219,56 @@ fn default_onboarding_flag_constant_points_to_hidden_marker() {
|
||||
// stays stable across refactors.
|
||||
assert_eq!(DEFAULT_ONBOARDING_FLAG_NAME, ".skip_onboarding");
|
||||
}
|
||||
|
||||
// ── autonomy settings handlers ───────────────────────────────
|
||||
|
||||
use crate::openhuman::config::TEST_ENV_LOCK;
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_get_autonomy_settings_returns_current_value() {
|
||||
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
}
|
||||
// Seed a known value before reading.
|
||||
let _ = crate::openhuman::config::ops::load_and_apply_autonomy_settings(
|
||||
crate::openhuman::config::ops::AutonomySettingsPatch {
|
||||
max_actions_per_hour: Some(123),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed");
|
||||
|
||||
let out = super::handle_get_autonomy_settings(serde_json::Map::new())
|
||||
.await
|
||||
.expect("handler");
|
||||
// into_cli_compatible_json wraps data under "result" when logs are present.
|
||||
let inner = out.get("result").unwrap_or(&out);
|
||||
let value = inner.get("max_actions_per_hour").and_then(|v| v.as_u64());
|
||||
assert_eq!(value, Some(123));
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_WORKSPACE");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_update_autonomy_settings_rejects_invalid_value() {
|
||||
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
}
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert("max_actions_per_hour".into(), serde_json::json!(0));
|
||||
|
||||
let err = super::handle_update_autonomy_settings(params)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("between 1 and 10000"), "got: {err}");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_WORKSPACE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6755,3 +6755,95 @@ async fn json_rpc_stale_auth_profile_lock_auto_recovered() {
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn json_rpc_config_autonomy_settings_roundtrip() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
write_min_config_with_local_ai_disabled(&openhuman_home, &mock_origin);
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// GET → expect the default (20).
|
||||
let initial = post_json_rpc(
|
||||
&rpc_base,
|
||||
7001,
|
||||
"openhuman.config_get_autonomy_settings",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let initial_outer = assert_no_jsonrpc_error(&initial, "get_autonomy_settings initial");
|
||||
// assert_no_jsonrpc_error already strips the JSON-RPC envelope; one more hop
|
||||
// strips the into_cli_compatible_json wrapper to reach the payload fields.
|
||||
let initial_value = initial_outer
|
||||
.get("result")
|
||||
.and_then(|r| r.get("max_actions_per_hour"))
|
||||
.and_then(Value::as_u64);
|
||||
assert_eq!(
|
||||
initial_value,
|
||||
Some(20),
|
||||
"expected default 20, got envelope: {initial_outer}"
|
||||
);
|
||||
|
||||
// UPDATE → 250.
|
||||
let update = post_json_rpc(
|
||||
&rpc_base,
|
||||
7002,
|
||||
"openhuman.config_update_autonomy_settings",
|
||||
json!({ "max_actions_per_hour": 250 }),
|
||||
)
|
||||
.await;
|
||||
assert_no_jsonrpc_error(&update, "update_autonomy_settings");
|
||||
|
||||
// GET again → expect 250.
|
||||
let after = post_json_rpc(
|
||||
&rpc_base,
|
||||
7003,
|
||||
"openhuman.config_get_autonomy_settings",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let after_outer = assert_no_jsonrpc_error(&after, "get_autonomy_settings after");
|
||||
let after_value = after_outer
|
||||
.get("result")
|
||||
.and_then(|r| r.get("max_actions_per_hour"))
|
||||
.and_then(Value::as_u64);
|
||||
assert_eq!(
|
||||
after_value,
|
||||
Some(250),
|
||||
"expected 250 after update, got envelope: {after_outer}"
|
||||
);
|
||||
|
||||
// Invalid value rejected — server returns JSON-RPC error envelope, not a result.
|
||||
let bad = post_json_rpc(
|
||||
&rpc_base,
|
||||
7004,
|
||||
"openhuman.config_update_autonomy_settings",
|
||||
json!({ "max_actions_per_hour": 99999 }),
|
||||
)
|
||||
.await;
|
||||
let bad_err = assert_jsonrpc_error(&bad, "update_autonomy_settings bad value");
|
||||
let err_message = bad_err
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("error object missing message: {bad_err}"));
|
||||
assert!(
|
||||
err_message.contains("between 1 and 10000"),
|
||||
"expected validation error in: {err_message}"
|
||||
);
|
||||
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user