import { afterEach, describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { AppConfig, DEFAULT_LLM_RETRY_CONFIG, isExecutionWorker, loadConfig, ReflectionConfig, toSnakeKeys, validateConfig } from './config.js'; describe('toSnakeKeys', () => { it('converts camelCase keys to snake_case', () => { expect(toSnakeKeys({ baseUrl: 'http://x', maxAttempts: 3 })) .toEqual({ base_url: 'http://x', max_attempts: 3 }); }); it('handles nested objects', () => { expect(toSnakeKeys({ provider: { backoffMs: [100, 200] } })) .toEqual({ provider: { backoff_ms: [100, 200] } }); }); it('preserves arrays of primitives', () => { expect(toSnakeKeys({ roles: ['auto', 'fast'] })) .toEqual({ roles: ['auto', 'fast'] }); }); it('handles arrays of objects', () => { expect(toSnakeKeys({ workers: [{ maxConcurrency: 2 }] })) .toEqual({ workers: [{ max_concurrency: 2 }] }); }); it('returns primitives as-is', () => { expect(toSnakeKeys('hello')).toBe('hello'); expect(toSnakeKeys(42)).toBe(42); expect(toSnakeKeys(null)).toBe(null); }); }); describe('loadConfig provider.retry', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); it('loads provider.retry from YAML', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', ' retry:', ' max_attempts: 5', ' backoff_ms:', ' - 200', ' - 400', ' retryable_status:', ' - 429', ' - 503', ].join('\n')); const config = loadConfig(configPath); expect(config.provider.retry).toEqual({ maxAttempts: 5, backoffMs: [200, 400], retryableStatus: [429, 503], }); }); it('uses the default provider.retry when not configured', () => { const config = loadConfig(join(tmpdir(), 'missing-config.yaml')); expect(config.provider.retry).toEqual(DEFAULT_LLM_RETRY_CONFIG); }); it('converts deprecated profiles to roles via shim', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', ' workers:', ' - id: gpu-fast', ' endpoint: http://fast.example/v1', ' profiles: [fast]', ].join('\n')); const config = loadConfig(configPath); expect(config.provider.workers[0]).toEqual(expect.objectContaining({ enabled: true, roles: ['fast'], })); expect(config.provider.workers[0]!.profiles).toBeUndefined(); }); it('uses roles directly when specified', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', ' workers:', ' - id: gpu1', ' endpoint: http://gpu1.example/v1', ' roles: [fast, title]', ].join('\n')); const config = loadConfig(configPath); expect(config.provider.workers[0]).toEqual(expect.objectContaining({ roles: ['fast', 'title'], })); }); it('defaults proxy=false when omitted', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', ' workers:', ' - id: gpu1', ' endpoint: http://gpu1.example/v1', ].join('\n')); const config = loadConfig(configPath); expect(config.provider.workers[0]!.proxy).toBe(false); expect(config.provider.workers[0]!.proxyType).toBeUndefined(); }); it('accepts proxy=true with default proxyType=litellm', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', ' workers:', ' - id: team-pool', ' endpoint: http://litellm:4000/v1', ' proxy: true', ' api_key: tok-abc', ].join('\n')); const config = loadConfig(configPath); expect(config.provider.workers[0]).toEqual(expect.objectContaining({ id: 'team-pool', proxy: true, proxyType: 'litellm', apiKey: 'tok-abc', })); }); it('preserves proxyType when explicitly set', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', ' workers:', ' - id: team-pool', ' endpoint: http://litellm:4000/v1', ' proxy: true', ' proxy_type: litellm', ].join('\n')); const config = loadConfig(configPath); expect(config.provider.workers[0]!.proxyType).toBe('litellm'); }); }); const DEFAULT_REFLECTION_FOR_TEST: ReflectionConfig = { enabled: false, workerRequired: true, maxMemoryChangesPerJob: 3, maxEntryBodyBytes: 8192, pieceEditCooldownHours: 24, snapshotRetentionDays: 90, activityLogMaxBytes: 4096, abstainRateFloor: 0.3, perUserDailyBudgetTokens: 200_000, snapshotMaxBytesPerUser: 100 * 1024 * 1024, snapshotMaxBytesPerEntry: 1 * 1024 * 1024, storeLlmRaw: false, }; function makeValidConfig(): AppConfig { return { provider: { model: 'qwen3:32b', workers: [{ id: 'default', endpoint: 'http://localhost:11434/v1' }], retry: { maxAttempts: 3, backoffMs: [2000, 5000, 15000], retryableStatus: [429, 500, 502, 503, 504], }, }, worktreeDir: '/var/lib/maestro/workspaces', concurrency: 1, maxMovements: 30, retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900], }, ask: { maxPerJob: 2, }, subtasks: { maxDepth: 2, maxPerParent: 10, }, safety: { maxIterations: 200, maxRevisits: 3, }, reflection: { ...DEFAULT_REFLECTION_FOR_TEST }, }; } describe('validateConfig', () => { it('valid default config passes with no errors', () => { const config = makeValidConfig(); expect(validateConfig(config)).toHaveLength(0); }); it('provider.model may be empty string (no error)', () => { const config = makeValidConfig(); config.provider.model = ''; const errors = validateConfig(config); expect(errors.some(e => e.includes('provider.model'))).toBe(false); }); it('provider.model may be omitted (no error)', () => { const config = makeValidConfig(); config.provider.model = undefined; const errors = validateConfig(config); expect(errors.some(e => e.includes('provider.model'))).toBe(false); }); it('invalid concurrency (0) produces error', () => { const config = makeValidConfig(); config.concurrency = 0; const errors = validateConfig(config); expect(errors.some(e => e.includes('concurrency'))).toBe(true); }); it('invalid concurrency (-1) produces error', () => { const config = makeValidConfig(); config.concurrency = -1; const errors = validateConfig(config); expect(errors.some(e => e.includes('concurrency'))).toBe(true); }); it('invalid concurrency (float) produces error', () => { const config = makeValidConfig(); config.concurrency = 1.5; const errors = validateConfig(config); expect(errors.some(e => e.includes('concurrency'))).toBe(true); }); it('invalid maxMovements (0) produces error', () => { const config = makeValidConfig(); config.maxMovements = 0; const errors = validateConfig(config); expect(errors.some(e => e.includes('maxMovements'))).toBe(true); }); it('invalid ask.maxPerJob (-1) produces error', () => { const config = makeValidConfig(); config.ask.maxPerJob = -1; const errors = validateConfig(config); expect(errors.some(e => e.includes('ask.maxPerJob'))).toBe(true); }); it('ask.maxPerJob (0) is valid', () => { const config = makeValidConfig(); config.ask.maxPerJob = 0; const errors = validateConfig(config); expect(errors.some(e => e.includes('ask.maxPerJob'))).toBe(false); }); it('invalid subtasks.maxDepth (-1) produces error', () => { const config = makeValidConfig(); config.subtasks.maxDepth = -1; const errors = validateConfig(config); expect(errors.some(e => e.includes('subtasks.maxDepth'))).toBe(true); }); it('subtasks.maxDepth (0) is valid', () => { const config = makeValidConfig(); config.subtasks.maxDepth = 0; const errors = validateConfig(config); expect(errors.some(e => e.includes('subtasks.maxDepth'))).toBe(false); }); it('invalid retry.maxAttempts (0) produces error', () => { const config = makeValidConfig(); config.retry.maxAttempts = 0; const errors = validateConfig(config); expect(errors.some(e => e.includes('retry.maxAttempts'))).toBe(true); }); it('invalid retry.backoffSeconds (empty array) produces error', () => { const config = makeValidConfig(); config.retry.backoffSeconds = []; const errors = validateConfig(config); expect(errors.some(e => e.includes('retry.backoffSeconds'))).toBe(true); }); it('worker with empty id produces error', () => { const config = makeValidConfig(); config.provider.workers = [{ id: '', endpoint: 'http://localhost:11434/v1' }]; const errors = validateConfig(config); expect(errors.some(e => e.includes('empty id'))).toBe(true); }); it('worker with empty endpoint produces error', () => { const config = makeValidConfig(); config.provider.workers = [{ id: 'w1', endpoint: '' }]; const errors = validateConfig(config); expect(errors.some(e => e.includes('endpoint'))).toBe(true); }); it('empty workers array produces error', () => { const config = makeValidConfig(); config.provider.workers = []; const errors = validateConfig(config); expect(errors.some(e => e.includes('provider.workers'))).toBe(true); }); it('rejects non-boolean proxy field (string "true")', () => { // YAML `proxy: "true"` (quoted) parses to a string. Without this // check the worker would silently run in direct mode but still ship // its apiKey as a Bearer token — credential-leak footgun. const config = makeValidConfig(); config.provider.workers = [ { id: 'team-pool', endpoint: 'http://litellm:4000/v1', // @ts-expect-error — intentionally invalid value for validator test proxy: 'true', }, ]; const errors = validateConfig(config); expect(errors.some(e => e.includes('proxy must be boolean'))).toBe(true); }); it('rejects non-boolean proxy field (number 1)', () => { const config = makeValidConfig(); config.provider.workers = [ { id: 'team-pool', endpoint: 'http://litellm:4000/v1', // @ts-expect-error — intentionally invalid value for validator test proxy: 1, }, ]; const errors = validateConfig(config); expect(errors.some(e => e.includes('proxy must be boolean'))).toBe(true); }); it('rejects non-boolean proxy field (null)', () => { const config = makeValidConfig(); config.provider.workers = [ { id: 'team-pool', endpoint: 'http://litellm:4000/v1', // @ts-expect-error — intentionally invalid value for validator test proxy: null, }, ]; const errors = validateConfig(config); expect(errors.some(e => e.includes('proxy must be boolean'))).toBe(true); }); it('proxy=true (boolean) and proxy=false (boolean) are both accepted', () => { const config = makeValidConfig(); config.provider.workers = [ { id: 't', endpoint: 'http://litellm:4000/v1', proxy: true }, { id: 'd', endpoint: 'http://gpu:11434/v1', proxy: false }, ]; const errors = validateConfig(config); expect(errors.some(e => e.includes('proxy'))).toBe(false); }); it('rejects unsupported proxy_type value', () => { const config = makeValidConfig(); config.provider.workers = [ { id: 'team-pool', endpoint: 'http://litellm:4000/v1', proxy: true, // @ts-expect-error — intentionally invalid value for validator test proxyType: 'openrouter', }, ]; const errors = validateConfig(config); expect(errors.some(e => e.includes('proxy_type'))).toBe(true); }); it('safety.maxIterations (0) produces error', () => { const config = makeValidConfig(); config.safety = { maxIterations: 0 }; const errors = validateConfig(config); expect(errors.some(e => e.includes('safety.maxIterations'))).toBe(true); }); it('safety.maxRevisits (-1) produces error', () => { const config = makeValidConfig(); config.safety = { maxRevisits: -1 }; const errors = validateConfig(config); expect(errors.some(e => e.includes('safety.maxRevisits'))).toBe(true); }); it('undefined safety passes validation', () => { const config = makeValidConfig(); config.safety = undefined; const errors = validateConfig(config); expect(errors.some(e => e.includes('safety'))).toBe(false); }); it('safety.promptGuardRatio in [0.5, 0.95] is valid', () => { const config = makeValidConfig(); for (const r of [0.5, 0.7, 0.8, 0.9, 0.95]) { config.safety = { promptGuardRatio: r }; const errors = validateConfig(config); expect(errors.some(e => e.includes('promptGuardRatio'))).toBe(false); } }); it('safety.promptGuardRatio out of range produces error', () => { const config = makeValidConfig(); for (const r of [0.49, 0.96, 1.2, -0.1, 0]) { config.safety = { promptGuardRatio: r }; const errors = validateConfig(config); expect(errors.some(e => e.includes('promptGuardRatio'))).toBe(true); } }); it('safety.promptGuardRatio non-number produces error', () => { const config = makeValidConfig(); config.safety = { promptGuardRatio: 'high' as unknown as number }; const errors = validateConfig(config); expect(errors.some(e => e.includes('promptGuardRatio'))).toBe(true); }); it('safety.historySummarization with valid fields passes', () => { const config = makeValidConfig(); config.safety = { historySummarization: { enabled: true, tailTurns: 2, preserveRecentBudget: 8000 }, }; expect(validateConfig(config)).toHaveLength(0); }); it('safety.historySummarization.tailTurns negative produces error', () => { const config = makeValidConfig(); config.safety = { historySummarization: { tailTurns: -1 } }; const errors = validateConfig(config); expect(errors.some(e => e.includes('historySummarization.tailTurns'))).toBe(true); }); it('safety.historySummarization.preserveRecentBudget zero produces error', () => { const config = makeValidConfig(); config.safety = { historySummarization: { preserveRecentBudget: 0 } }; const errors = validateConfig(config); expect(errors.some(e => e.includes('historySummarization.preserveRecentBudget'))).toBe(true); }); it('invalid provider.retry.maxAttempts (0) produces error', () => { const config = makeValidConfig(); config.provider.retry = { maxAttempts: 0, backoffMs: [1000], retryableStatus: [500] }; const errors = validateConfig(config); expect(errors.some(e => e.includes('provider.retry.maxAttempts'))).toBe(true); }); it('valid config with all optional fields set passes', () => { const config = makeValidConfig(); config.safety = { maxIterations: 100, maxRevisits: 5 }; config.provider.retry = { maxAttempts: 5, backoffMs: [1000, 3000], retryableStatus: [429, 500] }; config.ask.maxPerJob = 0; config.subtasks.maxDepth = 0; expect(validateConfig(config)).toHaveLength(0); }); describe('provider.metrics.prefix length cap (Phase 3b post-review)', () => { it('rejects 1-character prefix (under length 2)', () => { const config = makeValidConfig(); config.provider.metrics = { prefix: 'a' }; const errors = validateConfig(config); expect(errors.some(e => /prefix length must be 2-64/.test(e))).toBe(true); }); it('rejects 65-character prefix (over length 64)', () => { const config = makeValidConfig(); config.provider.metrics = { prefix: 'a'.repeat(65) }; const errors = validateConfig(config); expect(errors.some(e => /prefix length must be 2-64/.test(e))).toBe(true); }); it('accepts 64-character prefix', () => { const config = makeValidConfig(); config.provider.metrics = { prefix: 'a'.repeat(64) }; const errors = validateConfig(config); expect(errors.filter(e => /prefix length/.test(e))).toEqual([]); }); it('accepts 2-character prefix', () => { const config = makeValidConfig(); config.provider.metrics = { prefix: 'ab' }; const errors = validateConfig(config); expect(errors.filter(e => /prefix length/.test(e))).toEqual([]); }); }); }); describe('reflection role', () => { it('treats reflection-only worker as execution worker', () => { expect(isExecutionWorker({ id: 'r1', endpoint: 'http://localhost:11434/v1', model: 'm', roles: ['reflection'], maxConcurrency: 1, } as any)).toBe(true); }); }); describe('safety.bashSandbox', () => { it('defaults to "auto" when unset', () => { // loadConfig falls back to defaults when the file does not exist const cfg = loadConfig(join(tmpdir(), 'missing-bash-sandbox-config.yaml')); expect(cfg.safety?.bashSandbox).toBe('auto'); }); it('accepts auto|always|off', () => { for (const v of ['auto', 'always', 'off'] as const) { const config = makeValidConfig(); config.safety = { bashSandbox: v }; expect(validateConfig(config)).toHaveLength(0); } }); it('rejects invalid value', () => { const config = makeValidConfig(); config.safety = { bashSandbox: 'loose' as unknown as 'auto' | 'always' | 'off' }; const errors = validateConfig(config); expect(errors.some(e => /bashSandbox must be one of/.test(e))).toBe(true); }); }); describe('reflection config section', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); it('has default reflection block when not specified', () => { const cfg = loadConfig(join(tmpdir(), 'missing-config.yaml')); expect(cfg.reflection.enabled).toBe(false); expect(cfg.reflection.maxMemoryChangesPerJob).toBe(3); expect(cfg.reflection.snapshotRetentionDays).toBe(90); }); it('user overrides merge with defaults', () => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-')); const configPath = join(tempDir, 'config.yaml'); writeFileSync(configPath, [ 'provider:', ' model: test-model', 'reflection:', ' enabled: true', ' max_memory_changes_per_job: 5', ].join('\n')); const cfg = loadConfig(configPath); expect(cfg.reflection.enabled).toBe(true); expect(cfg.reflection.maxMemoryChangesPerJob).toBe(5); expect(cfg.reflection.snapshotRetentionDays).toBe(90); }); }); describe('loadConfig file cache', () => { const dirs: string[] = []; afterEach(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); dirs.length = 0; }); function tmpCfg(content: string): string { const d = mkdtempSync(join(tmpdir(), 'maestro-cfg-')); dirs.push(d); const p = join(d, 'config.yaml'); writeFileSync(p, content); return p; } it('returns the same value on a repeated unchanged load (cache hit)', () => { const p = tmpCfg('concurrency: 9\n'); expect(loadConfig(p).concurrency).toBe(9); expect(loadConfig(p).concurrency).toBe(9); }); it('reflects a changed file (cache invalidated by mtime/size)', () => { const p = tmpCfg('concurrency: 7\n'); expect(loadConfig(p).concurrency).toBe(7); writeFileSync(p, 'concurrency: 11\nmax_iterations: 5\n'); expect(loadConfig(p).concurrency).toBe(11); }); });