sync: update from private repo (d3780b00)
CI / build-and-test (push) Failing after 6m23s

This commit is contained in:
oss-sync
2026-07-09 00:12:24 +00:00
parent e8d38a104d
commit d41ff0f658
56 changed files with 1687 additions and 117 deletions
+19 -2
View File
@@ -198,7 +198,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
};
const jobStatus = task.latestJob?.status;
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams } = useJobStream(task.id, jobStatus);
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus);
// Most-recent content-field tool with decoded content to show live.
const liveToolContent = useMemo(() => {
@@ -287,6 +287,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2>
<span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span>
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
{(task.latestJob?.attempt ?? 1) > 1 && jobStatus !== 'succeeded' && (
<div
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15"
title={task.latestJob?.abortReason
? t('pane.attemptBadgeTitle', { reason: task.latestJob.abortReason })
: undefined}
data-testid="attempt-badge"
>
<span className="text-[10px] font-medium text-amber-700 dark:text-amber-300">
{t('pane.attemptBadge', { attempt: task.latestJob?.attempt, max: task.latestJob?.maxAttempts ?? 3 })}
</span>
</div>
)}
{isBusy && (
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
isWaitingSubtasks
@@ -417,7 +430,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
{t('pane.agentResponding')}
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
: t('pane.agentResponding')}
</div>
)}
</div>
+2 -2
View File
@@ -63,11 +63,11 @@ const CATEGORIES: Array<{ id: string; label: string; kinds: string[]; tone: stri
{ id: 'run', label: 'Run', kinds: ['run_start', 'run_complete'], tone: 'bg-surface-2 text-slate-700 border-hairline' },
{ id: 'movement', label: 'Movement', kinds: ['movement_start', 'movement_complete', 'transition', 'complete'], tone: 'bg-blue-50 text-blue-800 border-blue-100 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30' },
{ id: 'tool', label: 'Tool', kinds: ['tool_call', 'tool_result'], tone: 'bg-canvas text-slate-700 border-hairline' },
{ id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' },
{ id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end', 'llm_call_retry'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' },
{ id: 'cache', label: 'Cache', kinds: ['cache_set', 'cache_hit', 'cache_invalidate'], tone: 'bg-amber-50 text-amber-800 border-amber-100 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30' },
{ id: 'memory', label: 'Memory', kinds: ['memory_invalidate', 'memory_update_call', 'memory_handoff_write', 'memory_handoff_read', 'memory_delta_write', 'memory_delta_absorb', 'memory_snapshot_written', 'memory_snapshot_failed'], tone: 'bg-emerald-50 text-emerald-800 border-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30' },
{ id: 'watchdog', label: 'Watchdog', kinds: ['watchdog_fire', 'followup_detected'], tone: 'bg-red-50 text-red-800 border-red-100 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30' },
{ id: 'context', label: 'Context', kinds: ['context_action'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' },
{ id: 'context', label: 'Context', kinds: ['context_action', 'context_recovery'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' },
];
/**
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
/**
* Component tests for AskSubtasksForm — focus on the newly surfaced
* subtasks.spawnStaggerMs field.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { AskSubtasksForm } from './AskSubtasksForm';
describe('AskSubtasksForm', () => {
it('shows spawnStaggerMs default (1000) in the last number field', () => {
const onChange = vi.fn();
renderWithProviders(<AskSubtasksForm config={{ subtasks: {} }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
expect(numbers[numbers.length - 1]).toHaveValue(1000);
});
it('writes subtasks.spawnStaggerMs as a Number', async () => {
const onChange = vi.fn();
const { getConfig } = renderStatefulForm(AskSubtasksForm, { subtasks: { spawnStaggerMs: 1000 } }, { onChangeSpy: onChange });
const numbers = screen.getAllByRole('spinbutton');
const field = numbers[numbers.length - 1];
fireEvent.change(field, { target: { value: '250' } });
expect(getConfig().subtasks.spawnStaggerMs).toBe(250);
expect(onChange).toHaveBeenCalledWith('subtasks.spawnStaggerMs', 250);
});
it('serializes spawnStaggerMs as undefined when cleared', async () => {
const onChange = vi.fn();
renderWithProviders(<AskSubtasksForm config={{ subtasks: { spawnStaggerMs: 1000 } }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[numbers.length - 1]);
expect(onChange).toHaveBeenLastCalledWith('subtasks.spawnStaggerMs', undefined);
});
});
@@ -29,6 +29,12 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
<FieldInput type="number" value={subtasks.maxPerParent ?? ''} onChange={v => onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} />
<HelpText>{t('askSubtasks.maxPerParentHelp')}</HelpText>
</div>
<div>
<FieldLabel>Subtasks: Spawn Stagger (ms)</FieldLabel>
<FieldInput type="number" value={subtasks.spawnStaggerMs ?? 1000} onChange={v => onChange('subtasks.spawnStaggerMs', v === '' ? undefined : Number(v))} />
<HelpText>{t('askSubtasks.spawnStaggerHelp')}</HelpText>
</div>
</div>
);
}
@@ -24,6 +24,7 @@ import { MemoryLearningForm } from './MemoryLearningForm';
import { MetricsForm } from './MetricsForm';
import { ServerTlsForm } from './ServerTlsForm';
import { ReflectionForm } from './ReflectionForm';
import { SkillsQuotaForm } from './SkillsQuotaForm';
import { McpForm } from './McpForm';
import { SshForm } from './SshForm';
import { GatewayServerForm } from './GatewayServerForm';
@@ -242,6 +243,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
case 'context': return <ContextForm {...formProps} />;
case 'safety': return <SafetyForm {...formProps} />;
case 'reflection': return <ReflectionForm {...formProps} />;
case 'skills-quota': return <SkillsQuotaForm {...formProps} />;
case 'push-notifications': return <PushNotificationsForm {...formProps} />;
case 'auth': return <AuthForm {...formProps} />;
@@ -65,6 +65,17 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
/>
<HelpText>{t('execution.backoffHelp')}</HelpText>
</div>
<section className="mt-4 pt-3 border-t border-slate-200 space-y-2">
<h3 className="text-sm font-medium text-slate-600">{t('execution.pythonPanel.title')}</h3>
<div className="rounded-md border border-hairline bg-surface px-3 py-2.5 text-xs text-slate-600 dark:text-slate-300 space-y-1.5">
<p>{t('execution.pythonPanel.intro')}</p>
<p>{t('execution.pythonPanel.preinstalled')}</p>
<p>{t('execution.pythonPanel.blocked')}</p>
<p>{t('execution.pythonPanel.howToAdd')}</p>
<p className="text-slate-500 dark:text-slate-400">{t('execution.pythonPanel.roadmap')}</p>
</div>
</section>
</div>
);
}
@@ -67,6 +67,7 @@ interface GatewayConfigShape {
requestTimeoutSec?: number;
upstreamTimeoutSec?: number;
shutdownGracefulSec?: number;
internalTeams?: string[];
backends?: GatewayBackend[];
virtualKeys?: unknown[];
}
@@ -189,6 +190,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v);
const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v);
const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', v);
const setInternalTeams = (teams: string[]) => onChange('gateway.internalTeams', teams.length ? teams : undefined);
const updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => {
const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b));
@@ -255,6 +257,20 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
</div>
</div>
<div className="border-t border-hairline pt-3">
<FieldLabel>{t('gateway.server.internalTeamsLabel')}</FieldLabel>
<FieldInput
value={(gw.internalTeams ?? []).join(', ')}
onChange={v =>
setInternalTeams(
v.split(',').map(s => s.trim()).filter(s => s.length > 0),
)
}
placeholder="orchestrator, ops"
/>
<HelpText>{t('gateway.server.internalTeamsHelp')}</HelpText>
</div>
<div className="border-t border-hairline pt-3">
<div className="flex items-center justify-between mb-1.5">
<h3 className="text-sm font-medium text-slate-700">Backends</h3>
@@ -123,4 +123,19 @@ describe('LlmWorkersForm', () => {
});
expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull();
});
it('serializes llm.maxStreamMinutes as undefined when cleared', async () => {
const { onChange } = render({ llm: { workers: [], maxStreamMinutes: 20 } });
const field = (screen.getAllByRole('spinbutton') as HTMLInputElement[]).find(n => n.value === '20')!;
await userEvent.clear(field);
expect(onChange).toHaveBeenLastCalledWith('llm.maxStreamMinutes', undefined);
});
it('clears a per-worker healthcheckIntervalSeconds to undefined', async () => {
const { onChange } = render({ llm: { workers: [{ id: 'w1', healthcheckIntervalSeconds: 30 }] } });
const field = (screen.getAllByRole('spinbutton') as HTMLInputElement[]).find(n => n.value === '30')!;
await userEvent.clear(field);
const call = onChange.mock.calls.filter(([p]: [string]) => p === 'llm.workers').at(-1);
expect(call?.[1][0].healthcheckIntervalSeconds).toBeUndefined();
});
});
@@ -24,6 +24,9 @@ interface LlmWorker {
maxConcurrency?: number;
enabled?: boolean;
vlm?: boolean;
/** llama.cpp の prompt 評価進捗(return_progress)を要求。llama.cpp 系専用のオプトイン。 */
returnProgress?: boolean;
healthcheckIntervalSeconds?: number;
/**
* Phase 1 compat: older `provider.workers[].proxy: true` rows are
* mapped to `connectionType: aao_gateway` by the normalizer. We
@@ -35,6 +38,7 @@ interface LlmWorker {
interface LlmConfigShape {
timeoutMinutes?: number;
maxStreamMinutes?: number;
retry?: {
maxAttempts?: number;
backoffMs?: number[];
@@ -285,6 +289,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
/>
</div>
<div>
<FieldLabel>{t('llmWorkers.healthcheckInterval')}</FieldLabel>
<FieldInput
type="number"
value={w.healthcheckIntervalSeconds ?? ''}
onChange={v => updateWorker(i, { healthcheckIntervalSeconds: v === '' ? undefined : Number(v) })}
/>
<HelpText>{t('llmWorkers.healthcheckIntervalHelp')}</HelpText>
</div>
<div className="flex items-center gap-5 pt-5 flex-wrap">
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
<input
@@ -307,6 +321,18 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
/>
VLM
</label>
<label
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
title={t('llmWorkers.returnProgressTitle')}
>
<input
type="checkbox"
checked={w.returnProgress === true}
onChange={e => updateWorker(i, { returnProgress: e.target.checked || undefined })}
className="rounded"
/>
{t('llmWorkers.returnProgress')}
</label>
</div>
</div>
</div>
@@ -335,6 +361,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
<HelpText>{t('llmWorkers.timeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Stream (minutes)</FieldLabel>
<FieldInput
type="number"
value={llm.maxStreamMinutes ?? ''}
onChange={v => onChange('llm.maxStreamMinutes', v === '' ? undefined : Number(v))}
/>
<HelpText>{t('llmWorkers.maxStreamHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
{t('llmWorkers.retryTitle')}
</h3>
+18 -1
View File
@@ -11,7 +11,7 @@
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { SafetyForm } from './SafetyForm';
@@ -80,4 +80,21 @@ describe('SafetyForm', () => {
await userEvent.selectOptions(screen.getByRole('combobox'), 'always');
expect(onChange).toHaveBeenCalledWith('safety.bashSandbox', 'always');
});
it('writes historySummarization.reserveCapTokens as a Number', async () => {
const { onChange, getConfig } = renderStateful({ safety: { historySummarization: { reserveCapTokens: 32000 } } });
const numbers = screen.getAllByRole('spinbutton');
// reserveCapTokens is the last number field (after tailTurns, preserveRecentBudget).
const field = numbers[numbers.length - 1];
fireEvent.change(field, { target: { value: '48000' } });
expect(getConfig().safety.historySummarization.reserveCapTokens).toBe(48000);
expect(onChange).toHaveBeenCalledWith('safety.historySummarization.reserveCapTokens', 48000);
});
it('serializes reserveCapTokens as undefined when cleared', async () => {
const { onChange } = render({ safety: { historySummarization: { reserveCapTokens: 32000 } } });
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[numbers.length - 1]);
expect(onChange).toHaveBeenLastCalledWith('safety.historySummarization.reserveCapTokens', undefined);
});
});
@@ -129,6 +129,13 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} />
<HelpText>{t('safety.preserveRecentHelp')}</HelpText>
</div>
<div>
<FieldLabel>Reserve Cap (tokens)</FieldLabel>
<FieldInput type="number" value={historySummarization.reserveCapTokens ?? 32000}
onChange={v => onChange('safety.historySummarization.reserveCapTokens', v === '' ? undefined : Number(v))} />
<HelpText>{t('safety.reserveCapHelp')}</HelpText>
</div>
</div>
);
}
@@ -80,4 +80,11 @@ describe('ServerTlsForm', () => {
await user.click(enableCheckbox);
expect(onChange).toHaveBeenCalledWith('server.tls.enabled', true);
});
it('changes the minimum TLS version select', async () => {
const user = userEvent.setup();
const onChange = render({ server: { tls: {} } });
await user.selectOptions(screen.getByRole('combobox'), 'TLSv1.3');
expect(onChange).toHaveBeenCalledWith('server.tls.minVersion', 'TLSv1.3');
});
});
+15 -1
View File
@@ -11,7 +11,7 @@ import type { SectionFormProps } from './types';
* httpRedirectPort, redirectHost, selfSignedHosts.
*
* Fields intentionally omitted from the UI (left to config.yaml):
* minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner
* selfSignedDir. The onChange path mechanism in ConfigFormInner
* uses setNestedValue which does a shallow-merge, so unedited fields are
* preserved on save automatically.
*
@@ -111,6 +111,20 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) {
<HelpText>{t('serverTls.hstsHelp')}</HelpText>
</div>
{/* Minimum TLS protocol version */}
<div>
<FieldLabel>{t('serverTls.minVersion')}</FieldLabel>
<select
value={tls.minVersion ?? 'TLSv1.2'}
onChange={e => onChange('server.tls.minVersion', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value="TLSv1.2">TLSv1.2</option>
<option value="TLSv1.3">TLSv1.3</option>
</select>
<HelpText>{t('serverTls.minVersionHelp')}</HelpText>
</div>
{/* HTTP redirect port(s) — accepts a single port or a comma-separated list */}
<div>
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
/**
* Component test for the Settings sidebar search box.
* i18n is not initialized, so t() returns the raw key; we assert on the literal
* English section labels and on the onSelectSection callback.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { SettingsSidebar } from './SettingsSidebar';
describe('SettingsSidebar search', () => {
it('filters to matching sections and jumps on click', async () => {
const onSelect = vi.fn();
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
// Before searching, the grouped nav shows the Safety nav button.
expect(screen.getByTestId('settings-nav-safety')).toBeInTheDocument();
const box = screen.getByTestId('settings-search');
await userEvent.type(box, 'deadline');
// Results list appears; Safety is a hit (deadline is one of its keywords).
const hit = screen.getByTestId('settings-search-result-safety');
expect(hit).toBeInTheDocument();
// Unrelated section is filtered out.
expect(screen.queryByTestId('settings-search-result-branding')).not.toBeInTheDocument();
await userEvent.click(hit);
expect(onSelect).toHaveBeenCalledWith('safety');
});
it('shows a no-results message for a non-matching query', async () => {
const onSelect = vi.fn();
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'zzzznotathing' } });
expect(screen.getByTestId('settings-search-results').textContent).toContain('search.noResults');
});
it('does not expose admin-only sections to a non-admin search', async () => {
const onSelect = vi.fn();
renderWithProviders(<SettingsSidebar isAdmin={false} onSelectSection={onSelect} />);
fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'safety' } });
// Safety is under an adminOnly group → not searchable for a non-admin.
expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument();
});
});
+56 -2
View File
@@ -1,4 +1,6 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
interface SettingsSidebarProps {
activeSection?: string;
@@ -66,6 +68,7 @@ export const CONFIG_GROUPS = [
{ id: 'context', label: 'Context' },
{ id: 'safety', label: 'Safety' },
{ id: 'reflection', label: 'Reflection' },
{ id: 'skills-quota', label: 'Skill Quotas' },
],
},
{
@@ -134,11 +137,62 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
const { t } = useTranslation('settings');
const [query, setQuery] = useState('');
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
// Only sections the current user can actually open are searchable.
const visibleIds = useMemo(
() => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))),
[visibleGroups],
);
const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]);
const results = useMemo(() => searchSettings(query, index), [query, index]);
const searching = query.trim().length > 0;
const labelFor = (id: string, fallback: string) => {
for (const g of visibleGroups) {
const s = g.sections.find(x => x.id === id);
if (s) return 'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label;
}
return fallback;
};
return (
<div className="h-full overflow-y-auto border-r border-hairline bg-canvas p-3">
{visibleGroups.map(group => (
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('search.placeholder')}
aria-label={t('search.placeholder')}
data-testid="settings-search"
className="mb-3 w-full rounded-md border border-hairline bg-surface px-2 py-1.5 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
{searching ? (
<div data-testid="settings-search-results">
{results.length === 0 ? (
<div className="px-2 py-1 text-xs text-slate-400">{t('search.noResults')}</div>
) : (
results.map(r => {
const active = activeSection === r.sectionId;
return (
<button
key={r.sectionId}
data-testid={`settings-search-result-${r.sectionId}`}
onClick={() => { onSelectSection(r.sectionId); setQuery(''); }}
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
active ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 hover:bg-surface'
}`}
>
<span>{labelFor(r.sectionId, r.label)}</span>
<span className="ml-1 text-2xs text-slate-400">· {r.group}</span>
</button>
);
})
)}
</div>
) : (
visibleGroups.map(group => (
<div key={group.label} className="mb-3">
<div className="section-label px-2 py-1">
{group.label}
@@ -154,7 +208,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set
</button>
))}
</div>
))}
)))}
</div>
);
}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
/**
* Component tests for SkillsQuotaForm (settings — skill store quotas).
*
* i18n is NOT initialized in the test env, so t() returns the raw key; we
* assert on roles/values, not labels.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { SkillsQuotaForm } from './SkillsQuotaForm';
describe('SkillsQuotaForm', () => {
it('renders all five quota fields with defaults', () => {
const onChange = vi.fn();
renderWithProviders(<SkillsQuotaForm config={{ skills: {} }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
expect(numbers).toHaveLength(5);
// Defaults: 50, 64, 5, 100, 2000
expect(numbers[0]).toHaveValue(50);
expect(numbers[1]).toHaveValue(64);
expect(numbers[2]).toHaveValue(5);
expect(numbers[3]).toHaveValue(100);
expect(numbers[4]).toHaveValue(2000);
});
it('writes skills.maxPerUser as a Number', async () => {
const onChange = vi.fn();
const { getConfig } = renderStatefulForm(SkillsQuotaForm, { skills: { maxPerUser: 50 } }, { onChangeSpy: onChange });
const numbers = screen.getAllByRole('spinbutton');
fireEvent.change(numbers[0], { target: { value: '12' } });
expect(getConfig().skills.maxPerUser).toBe(12);
expect(onChange).toHaveBeenCalledWith('skills.maxPerUser', 12);
});
it('serializes a cleared quota as undefined', async () => {
const onChange = vi.fn();
renderWithProviders(<SkillsQuotaForm config={{ skills: { maxIndexChars: 2000 } }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[4]);
expect(onChange).toHaveBeenLastCalledWith('skills.maxIndexChars', undefined);
});
});
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
/**
* Skill Quotas — per-user skill store limits (`skills.*`).
*
* These caps bound how many skills a user can install and how large the
* store may grow. They exist in the config schema (SkillsConfig) but were
* previously only editable via config.yaml. (Distinct from the SkillsForm
* panel under User Folder, which is the skill *store* browser/editor.)
*/
export function SkillsQuotaForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const skills = config.skills ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">{t('skillsQuota.title')}</h2>
<HelpText>{t('skillsQuota.intro')}</HelpText>
<div>
<FieldLabel>Max Per User</FieldLabel>
<FieldInput type="number" value={skills.maxPerUser ?? 50}
onChange={v => onChange('skills.maxPerUser', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxPerUserHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Skill Size (KB)</FieldLabel>
<FieldInput type="number" value={skills.maxSkillSizeKb ?? 64}
onChange={v => onChange('skills.maxSkillSizeKb', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxSkillSizeHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Total Size (MB)</FieldLabel>
<FieldInput type="number" value={skills.maxTotalSizeMb ?? 5}
onChange={v => onChange('skills.maxTotalSizeMb', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxTotalSizeHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max System Skills</FieldLabel>
<FieldInput type="number" value={skills.maxSystemSkills ?? 100}
onChange={v => onChange('skills.maxSystemSkills', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxSystemSkillsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Index Chars</FieldLabel>
<FieldInput type="number" value={skills.maxIndexChars ?? 2000}
onChange={v => onChange('skills.maxIndexChars', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxIndexCharsHelp')}</HelpText>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
/**
* Component tests for ToolsMediaForm — focus on the newly surfaced
* tools.officeMsgMaxSizeMb field.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { ToolsMediaForm } from './ToolsMediaForm';
describe('ToolsMediaForm', () => {
it('shows officeMsgMaxSizeMb default (25)', () => {
const onChange = vi.fn();
renderWithProviders(<ToolsMediaForm config={{ tools: {} }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
// The .msg field sits just before the Uploads field; assert the value is present.
expect(numbers.some(n => n.value === '25')).toBe(true);
});
it('writes tools.officeMsgMaxSizeMb as a Number', async () => {
const onChange = vi.fn();
renderStatefulForm(ToolsMediaForm, { tools: { officeMsgMaxSizeMb: 25 } }, { onChangeSpy: onChange });
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
const msgField = numbers.find(n => n.value === '25')!;
await userEvent.clear(msgField);
await userEvent.type(msgField, '40');
expect(onChange).toHaveBeenCalledWith('tools.officeMsgMaxSizeMb', expect.any(Number));
});
});
@@ -116,6 +116,12 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
</div>
<div>
<FieldLabel>{t('tools.office.msgLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officeMsgMaxSizeMb ?? 25}
onChange={v => onChange('tools.officeMsgMaxSizeMb', Number(v))} />
<HelpText>{t('tools.office.msgHelp')}</HelpText>
</div>
</section>
<section className="space-y-5 pt-2 border-t border-hairline">
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { CONFIG_GROUPS } from './SettingsSidebar';
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
describe('searchSettings', () => {
it('finds a section by a config-key keyword buried in a form', () => {
expect(searchSettings('reserve_cap').map(r => r.sectionId)).toContain('safety');
expect(searchSettings('deadline').map(r => r.sectionId)).toContain('safety');
expect(searchSettings('max_stream_minutes').map(r => r.sectionId)).toContain('llm-workers');
});
it('finds a section by concept in English or Japanese', () => {
expect(searchSettings('python').map(r => r.sectionId)).toContain('execution');
expect(searchSettings('tls').map(r => r.sectionId)).toContain('server-tls');
expect(searchSettings('証明書').map(r => r.sectionId)).toContain('server-tls');
expect(searchSettings('デッドライン').map(r => r.sectionId)).toContain('safety');
});
it('is AND across whitespace-separated terms', () => {
const r = searchSettings('browser timeout');
expect(r.map(x => x.sectionId)).toContain('tools-browser');
// "browser timeout" should not match, say, safety (has neither pair).
expect(r.every(x => `${x.label} ${x.keywords}`.toLowerCase().includes('browser'))).toBe(true);
});
it('is case-insensitive and returns nothing for an empty query', () => {
expect(searchSettings('SAFETY').map(r => r.sectionId)).toContain('safety');
expect(searchSettings(' ')).toEqual([]);
expect(searchSettings('')).toEqual([]);
});
it('can be scoped to a caller-supplied (e.g. non-admin) index subset', () => {
const onlyPrefs = buildSettingsSearchIndex().filter(e => e.sectionId === 'preferences');
// "safety" is admin-only → absent from a preferences-only index.
expect(searchSettings('safety', onlyPrefs)).toEqual([]);
expect(searchSettings('preferences', onlyPrefs).map(r => r.sectionId)).toEqual(['preferences']);
});
});
describe('index integrity (drift guard)', () => {
const allIds = CONFIG_GROUPS.flatMap(g => g.sections.map(s => s.id));
it('covers every sidebar section (no section left unsearchable)', () => {
const index = buildSettingsSearchIndex();
const indexedIds = new Set(index.map(e => e.sectionId));
for (const id of allIds) expect(indexedIds.has(id)).toBe(true);
});
it('every indexed section has non-empty keywords', () => {
for (const e of buildSettingsSearchIndex()) {
expect(e.keywords.trim().length, `section ${e.sectionId} has no keywords`).toBeGreaterThan(0);
}
});
});
@@ -0,0 +1,99 @@
/**
* settingsSearchIndex.ts — a lightweight static manifest for searching the
* admin Settings screen.
*
* The Settings sidebar has ~25 sections spread across 8 groups; the specific
* knob you want (e.g. "deadline", "reserve_cap_tokens", "min TLS version") is
* often buried in a form whose section name does not mention it. This manifest
* maps hand-authored keywords → sectionId so a search box can jump straight to
* the right section.
*
* Deliberately NOT auto-generated from the forms: keeping it a small explicit
* list is lower-risk (no form refactor) and lets us add synonyms / Japanese
* terms. A test asserts every entry points at a real section id and every
* admin section is covered, so the manifest cannot silently drift.
*/
import { CONFIG_GROUPS } from './SettingsSidebar';
export interface SettingsSearchEntry {
sectionId: string;
/** English display label (from the sidebar). */
label: string;
/** Group the section lives under (e.g. "Agent Runtime"). */
group: string;
/** Free-text keywords: config keys, concepts, and Japanese synonyms. */
keywords: string;
}
/** sectionId → extra searchable keywords (config keys, concepts, JP terms). */
const KEYWORDS: Record<string, string> = {
// Preference
preferences: 'preferences default visibility 公開範囲 個人設定 デフォルト',
notifications: 'notifications browser web push 通知 ブラウザ通知 購読',
pets: 'pets mascot chat backend worker ペット マスコット',
'memory-learning': 'reflection history memory learning revert 学習 メモリ 履歴 リフレクション',
'a2a-delegations': 'a2a delegation token revoke 委任 取り消し 外部エージェント',
// System
branding: 'branding app name logo favicon accent color テーマ ロゴ アプリ名 色',
'paths-storage': 'storage paths worktree upload dir data directory 保存先 ディレクトリ アップロード上限 worktree_dir',
execution: 'execution concurrency max_movements retry backoff python packages 並列度 リトライ 実行 サンドボックス python',
auth: 'authentication login google gitea oauth local password provider 認証 ログイン',
organizations: 'organizations org members visibility 組織 メンバー',
'push-notifications': 'web push vapid server 配信 サーバー鍵',
// LLM
'llm-workers': 'llm workers model endpoint api_key concurrency timeout max_stream_minutes healthcheck ワーカー モデル 接続 タイムアウト',
'gateway-server': 'gateway openai compatible virtual keys internal_teams listen port ゲートウェイ 仮想キー',
'llm-metrics': 'metrics prometheus exporter gateway 計測 メトリクス',
// Agent Runtime
'ask-subtasks': 'ask limit subtasks spawn_stagger_ms max_per_parent サブタスク 質問上限 間引き',
context: 'context threshold warn prompt force_transition token limit コンテキスト 閾値 使用率',
safety: 'safety max_iterations max_revisits deadline max_job_minutes grace bash sandbox network reserve_cap_tokens history summarization 自爆防止 デッドライン 反復 サンドボックス ネットワーク',
reflection: 'reflection learning cooldown budget auto apply 自動学習 クールダウン',
'skills-quota': 'skills quota max per user size limit maxPerUser maxSkillSizeKb maxTotalSizeMb maxSystemSkills maxIndexChars スキル クォータ 上限 個数 サイズ',
// Tools
'tools-web': 'web search websearch webfetch search_filter 検索 ウェブ',
'tools-browser': 'browser playwright browseweb timeout channel ブラウザ タイムアウト',
'tools-media': 'media vision ocr audio office excel docx pdf pptx msg size 画像 音声 文書 ファイル上限',
'tools-external': 'external api keys x twitter maps amazon youtube 外部サービス キー',
'search-filter': 'search filter domain allow deny websearch ドメイン 許可 除外',
// MCP & Connections
mcp: 'mcp model context protocol runtime quota サーバー クォータ',
// SSH
ssh: 'ssh remote connection grant audit master key 接続 監査 鍵ローテーション',
// Network
'server-tls': 'https tls certificate self signed hsts redirect min_version 証明書 リダイレクト 暗号化',
};
/** Build the flat search index from the sidebar's canonical section list. */
export function buildSettingsSearchIndex(): SettingsSearchEntry[] {
const entries: SettingsSearchEntry[] = [];
for (const group of CONFIG_GROUPS) {
for (const s of group.sections) {
entries.push({
sectionId: s.id,
label: s.label,
group: group.label,
keywords: KEYWORDS[s.id] ?? '',
});
}
}
return entries;
}
/**
* Case-insensitive AND search: every whitespace-separated term must appear in
* the section's label, group, id, or keywords. Empty query → no results (the
* caller shows the normal grouped nav instead).
*/
export function searchSettings(
query: string,
index: SettingsSearchEntry[] = buildSettingsSearchIndex(),
): SettingsSearchEntry[] {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return [];
return index.filter((e) => {
const haystack = `${e.label} ${e.group} ${e.sectionId} ${e.keywords}`.toLowerCase();
return terms.every((t) => haystack.includes(t));
});
}