/** * v0.28: tests for the unified model resolver. Pure-function-style tests using * a tiny stub engine — no DB, no PGLite, no Postgres needed. */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { resolveModel, resolveAlias, DEFAULT_ALIASES, TIER_DEFAULTS, isAnthropicProvider, _resetDeprecationWarningsForTest, } from '../src/core/model-config.ts'; class StubEngine { readonly kind = 'pglite' as const; private cfg = new Map(); set(key: string, value: string) { this.cfg.set(key, value); } async getConfig(key: string) { return this.cfg.get(key) ?? null; } // unused stubs to satisfy the BrainEngine duck-type at the resolveModel boundary async setConfig() {} } let stub: StubEngine; let stderrCapture: string; const origWrite = process.stderr.write.bind(process.stderr); beforeEach(() => { stub = new StubEngine(); stderrCapture = ''; process.stderr.write = ((chunk: string | Uint8Array) => { stderrCapture += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); return true; }) as typeof process.stderr.write; delete process.env.GBRAIN_MODEL; _resetDeprecationWarningsForTest(); }); afterEach(() => { process.stderr.write = origWrite; }); describe('resolveAlias', () => { test('built-in aliases resolve to full ids', async () => { expect(await resolveAlias(null, 'opus')).toBe(DEFAULT_ALIASES.opus); expect(await resolveAlias(null, 'sonnet')).toBe(DEFAULT_ALIASES.sonnet); expect(await resolveAlias(null, 'haiku')).toBe(DEFAULT_ALIASES.haiku); }); test('unknown alias passes through (treats as full id)', async () => { expect(await resolveAlias(null, 'claude-experimental-9000')).toBe('claude-experimental-9000'); }); test('user-defined alias overrides built-in', async () => { stub.set('models.aliases.opus', 'claude-opus-4-7-1m'); expect(await resolveAlias(stub as never, 'opus')).toBe('claude-opus-4-7-1m'); }); test('cycle in aliases breaks at depth 2', async () => { stub.set('models.aliases.a', 'b'); stub.set('models.aliases.b', 'a'); const result = await resolveAlias(stub as never, 'a'); expect(typeof result).toBe('string'); }); }); describe('resolveModel — 6-tier precedence', () => { test('CLI flag wins over everything', async () => { stub.set('models.dream.synthesize', 'sonnet'); stub.set('models.default', 'opus'); process.env.GBRAIN_MODEL = 'haiku'; const m = await resolveModel(stub as never, { cliFlag: 'gemini', configKey: 'models.dream.synthesize', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.gemini); }); test('new-key config wins over deprecated key, deprecated key wins over default', async () => { stub.set('models.dream.synthesize', 'opus'); stub.set('dream.synthesize.model', 'sonnet'); stub.set('models.default', 'haiku'); const m = await resolveModel(stub as never, { configKey: 'models.dream.synthesize', deprecatedConfigKey: 'dream.synthesize.model', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.opus); expect(stderrCapture).toContain('deprecated config "dream.synthesize.model" ignored'); }); test('deprecated key honored when new key absent (with warning)', async () => { stub.set('dream.synthesize.model', 'opus'); const m = await resolveModel(stub as never, { configKey: 'models.dream.synthesize', deprecatedConfigKey: 'dream.synthesize.model', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.opus); expect(stderrCapture).toContain('deprecated config "dream.synthesize.model" honored'); }); test('global default used when per-key keys absent', async () => { stub.set('models.default', 'opus'); const m = await resolveModel(stub as never, { configKey: 'models.dream.synthesize', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.opus); }); test('env var used when no config set', async () => { process.env.GBRAIN_MODEL = 'haiku'; const m = await resolveModel(stub as never, { configKey: 'models.dream.synthesize', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.haiku); }); test('hardcoded fallback last', async () => { const m = await resolveModel(stub as never, { configKey: 'models.dream.synthesize', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.sonnet); }); test('deprecation warning fires once per process per key', async () => { stub.set('dream.synthesize.model', 'opus'); await resolveModel(stub as never, { configKey: 'models.dream.synthesize', deprecatedConfigKey: 'dream.synthesize.model', fallback: 'sonnet', }); const firstWarn = stderrCapture; stderrCapture = ''; await resolveModel(stub as never, { configKey: 'models.dream.synthesize', deprecatedConfigKey: 'dream.synthesize.model', fallback: 'sonnet', }); expect(firstWarn).toContain('deprecated config'); expect(stderrCapture).toBe(''); }); }); describe('resolveModel — v0.31.12 tier system', () => { test('models.default beats tier override', async () => { stub.set('models.default', 'opus'); stub.set('models.tier.reasoning', 'haiku'); const m = await resolveModel(stub as never, { tier: 'reasoning', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.opus); }); test('models.tier. beats env + fallback', async () => { stub.set('models.tier.reasoning', 'opus'); process.env.GBRAIN_MODEL = 'haiku'; const m = await resolveModel(stub as never, { tier: 'reasoning', fallback: 'sonnet', }); expect(m).toBe(DEFAULT_ALIASES.opus); }); test('TIER_DEFAULTS wins over caller fallback when no override', async () => { const m = await resolveModel(stub as never, { tier: 'reasoning', fallback: 'haiku', }); expect(m).toBe(TIER_DEFAULTS.reasoning); }); test('v0.38 D7: tier.subagent accepts non-Anthropic models that support tools (with cost warn)', async () => { // Pre-v0.38 the resolver hard-fell-back to TIER_DEFAULTS.subagent for any // non-Anthropic model. v0.38 (D6/D7) replaces that with a capability check: // OpenAI/Gemini/etc. support tools → resolved unchanged + warn about // missing prompt caching (cost regression on long loops, not a refusal). stub.set('models.default', 'openai:gpt-5.2'); const m = await resolveModel(stub as never, { tier: 'subagent', fallback: 'sonnet', }); expect(m).toBe('openai:gpt-5.2'); expect(stderrCapture).toContain('caching'); }); test('v0.38 D7: tier.subagent rejects unknown providers (falls back to default)', async () => { // Unknown providers fail the capability check (verdict='unknown'); the // resolver falls back to TIER_DEFAULTS.subagent rather than burn money on // an unverified model. stub.set('models.tier.subagent', 'madeup-provider:weird-model'); const m = await resolveModel(stub as never, { tier: 'subagent', fallback: 'sonnet', }); expect(m).toBe(TIER_DEFAULTS.subagent); expect(stderrCapture).toContain('tier.subagent'); }); test('tier.subagent accepts explicit Anthropic override', async () => { stub.set('models.tier.subagent', 'anthropic:claude-opus-4-7'); const m = await resolveModel(stub as never, { tier: 'subagent', fallback: 'sonnet', }); expect(m).toBe('anthropic:claude-opus-4-7'); expect(stderrCapture).toBe(''); }); test('isAnthropicProvider matches provider-prefixed and bare claude-* ids', () => { expect(isAnthropicProvider('anthropic:claude-sonnet-4-6')).toBe(true); expect(isAnthropicProvider('claude-opus-4-7')).toBe(true); expect(isAnthropicProvider('openai:gpt-5.5')).toBe(false); expect(isAnthropicProvider('gemini-3-pro')).toBe(false); expect(isAnthropicProvider('')).toBe(false); }); test('v0.41.20.0: isAnthropicProvider classifies slash-form (subagent-guard fix)', () => { // Pre-fix: 'anthropic/claude-sonnet-4-6' had no colon and didn't start // with 'claude-' (started with 'anthropic') → returned false → silent // subagent-guard bypass → fall back to TIER_DEFAULTS.subagent without // honoring the user's explicit slash-form config. expect(isAnthropicProvider('anthropic/claude-sonnet-4-6')).toBe(true); expect(isAnthropicProvider('anthropic/claude-opus-4-7')).toBe(true); // Non-Anthropic slash forms STILL return false (don't accidentally // widen the guard). expect(isAnthropicProvider('openai/gpt-5')).toBe(false); expect(isAnthropicProvider('google/gemini-3-pro')).toBe(false); }); test('alias-chain conflict: forward + reverse for same id (Codex F6)', async () => { // Codex F6: if both forward and reverse aliases exist, depth cap (2) // prevents infinite loop. Canonicalization is deterministic — terminates // and returns a valid string, no NaN/undefined fall-through. stub.set('models.aliases.claude-sonnet-4-6', 'claude-sonnet-5'); stub.set('models.aliases.claude-sonnet-5', 'claude-sonnet-4-6'); const result = await resolveAlias(stub as never, 'claude-sonnet-4-6'); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); });