Files
gbrain/test/config-set.test.ts
T
4320527785 feat: support OpenRouter API key in config (#1714)
* feat: support OpenRouter API key in config

* fixup: dedupe openrouter_api_key vs master, drop no-op compile-guard test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:44:27 -07:00

282 lines
12 KiB
TypeScript

/**
* T8 — `gbrain config set` strict unknown-key rejection + --force + Levenshtein.
*
* These tests probe the pure helpers (KNOWN_CONFIG_KEYS list, prefix list,
* Levenshtein suggestion against the list). The full `runConfig` CLI
* integration that calls `engine.setConfig` is exercised E2E in T12.
*/
import { describe, test, expect, spyOn } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { KNOWN_CONFIG_KEYS, KNOWN_CONFIG_KEY_PREFIXES, isConfigTruthy } from '../src/core/config.ts';
import { suggestNearest } from '../src/core/levenshtein.ts';
import { runConfig } from '../src/commands/config.ts';
import { checkSubagentCapability } from '../src/commands/doctor.ts';
import { withEnv } from './helpers/with-env.ts';
import type { BrainEngine } from '../src/core/engine.ts';
describe('KNOWN_CONFIG_KEYS', () => {
test('contains the canonical embedding keys', () => {
expect(KNOWN_CONFIG_KEYS).toContain('embedding_model');
expect(KNOWN_CONFIG_KEYS).toContain('embedding_dimensions');
expect(KNOWN_CONFIG_KEYS).toContain('embedding_disabled'); // v0.37 D9
expect(KNOWN_CONFIG_KEYS).toContain('expansion_model');
expect(KNOWN_CONFIG_KEYS).toContain('chat_model');
expect(KNOWN_CONFIG_KEYS).toContain('openrouter_api_key');
expect(KNOWN_CONFIG_KEYS).toContain('provider_chat_options');
});
test('contains the search-mode keys (v0.32.3)', () => {
expect(KNOWN_CONFIG_KEYS).toContain('search.mode');
expect(KNOWN_CONFIG_KEYS).toContain('search.cache.enabled');
});
test('contains the models-tier keys (v0.31.12)', () => {
expect(KNOWN_CONFIG_KEYS).toContain('models.default');
expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent');
});
test('contains the dream synthesize timeout keys (#1594)', () => {
expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_timeout_ms');
expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_wait_timeout_ms');
});
test('contains the spend-control keys (v0.42.42.0, #2139) — no --force archaeology', () => {
expect(KNOWN_CONFIG_KEYS).toContain('spend.posture');
expect(KNOWN_CONFIG_KEYS).toContain('sync.cost_gate_min_usd');
expect(KNOWN_CONFIG_KEYS).toContain('sync.federated_v2');
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_cooldown_min');
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd_per_source_24h');
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd');
});
test('includes the gateway-loop toggle and provider API keys the wave wires', () => {
// The subagent handler's error message tells users to run
// `gbrain config set agent.use_gateway_loop true`; it must be a known key
// or `config set` rejects the wave's own enable command without --force.
expect(KNOWN_CONFIG_KEYS).toContain('agent.use_gateway_loop');
expect(KNOWN_CONFIG_KEYS).toContain('openrouter_api_key');
expect(KNOWN_CONFIG_KEYS).toContain('zeroentropy_api_key');
});
test('registers only the live conversation-parser fallback key', () => {
expect(KNOWN_CONFIG_KEYS).toContain('conversation_parser.llm_fallback_enabled');
expect(KNOWN_CONFIG_KEY_PREFIXES).not.toContain('conversation_parser.');
expect(KNOWN_CONFIG_KEYS).not.toContain('conversation_parser.llm_polish_enabled');
});
test('no duplicate entries', () => {
const set = new Set(KNOWN_CONFIG_KEYS);
expect(set.size).toBe(KNOWN_CONFIG_KEYS.length);
});
});
describe('KNOWN_CONFIG_KEY_PREFIXES', () => {
test('includes the well-known prefixes', () => {
expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('search.');
expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('models.');
expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('dream.');
expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('provider_chat_options.');
});
test('prefixes end in `.` (consistent shape)', () => {
for (const p of KNOWN_CONFIG_KEY_PREFIXES) {
expect(p).toMatch(/\.$/);
}
});
});
describe('Levenshtein suggestion against KNOWN_CONFIG_KEYS', () => {
test('bug-reporter case: embedding.model → embedding_model', () => {
const got = suggestNearest('embedding.model', KNOWN_CONFIG_KEYS, 3);
expect(got).toBe('embedding_model');
});
test('bug-reporter case: embedding.dimensions → embedding_dimensions', () => {
const got = suggestNearest('embedding.dimensions', KNOWN_CONFIG_KEYS, 3);
expect(got).toBe('embedding_dimensions');
});
test('bug-reporter case: embedding.provider has no perfect match', () => {
// `embedding.provider` is 6+ edits from any canonical key. Either no
// suggestion (returns null) OR suggests a close-ish key like
// `embedding_model`. Either outcome means the user sees a clear
// "unknown key" message + must pick a different name.
const got = suggestNearest('embedding.provider', KNOWN_CONFIG_KEYS, 3);
// The exact mapping depends on Levenshtein bucket; we just verify it
// doesn't accidentally suggest something completely unrelated.
if (got !== null) {
expect(got).toMatch(/^embedding/);
}
});
test('typo: chat_modle → chat_model', () => {
const got = suggestNearest('chat_modle', KNOWN_CONFIG_KEYS, 3);
expect(got).toBe('chat_model');
});
test('typo: search.modes → search.mode', () => {
const got = suggestNearest('search.modes', KNOWN_CONFIG_KEYS, 3);
expect(got).toBe('search.mode');
});
test('completely-unrelated key returns null', () => {
const got = suggestNearest('xyzzy_quux_blah_unrelated', KNOWN_CONFIG_KEYS, 3);
expect(got).toBeNull();
});
test('exact match returns identity (no suggestion noise)', () => {
const got = suggestNearest('embedding_model', KNOWN_CONFIG_KEYS, 3);
expect(got).toBe('embedding_model');
});
});
describe('prefix vs known-key gate logic (mirrored from runConfig)', () => {
// Replicate the gate logic the CLI uses to validate test coverage of
// the decision tree.
function gate(key: string): 'known' | 'prefix' | 'unknown' {
if (KNOWN_CONFIG_KEYS.includes(key)) return 'known';
if (KNOWN_CONFIG_KEY_PREFIXES.some(p => key.startsWith(p))) return 'prefix';
return 'unknown';
}
test('explicit known key → "known"', () => {
expect(gate('embedding_model')).toBe('known');
});
test('search.foo.bar (under prefix) → "prefix"', () => {
expect(gate('search.foo.bar')).toBe('prefix');
});
test('models.custom.x (under prefix) → "prefix"', () => {
expect(gate('models.custom.x')).toBe('prefix');
});
test('provider_chat_options.anthropic (under prefix) → "prefix"', () => {
expect(gate('provider_chat_options.anthropic')).toBe('prefix');
});
test('bug-reporter: embedding.provider → "unknown" (no prefix match)', () => {
expect(gate('embedding.provider')).toBe('unknown');
});
test('bug-reporter: embedding.model → "unknown"', () => {
expect(gate('embedding.model')).toBe('unknown');
});
test('bug-reporter: embedding.dimensions → "unknown"', () => {
expect(gate('embedding.dimensions')).toBe('unknown');
});
});
describe('#2753 — the doctor-proposed gateway-loop command is accepted by `config set`', () => {
// Pre-fix: `gbrain doctor --full` told users to run `gbrain config set
// agent.use_gateway_loop true`, but the key wasn't in KNOWN_CONFIG_KEYS,
// so the exact command doctor recommended failed with "Unknown config
// key" (or silently no-opped under --force with a false "nothing reads
// this" warning). This test drives `checkSubagentCapability` (the doctor
// check that emits the recommendation) end to end, extracts the literal
// command from its message, and feeds it into the real `runConfig` CLI
// entry point — so a future edit that lets the two drift apart again
// fails here instead of shipping.
const home = mkdtempSync(join(tmpdir(), 'gbrain-config-set-'));
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(
join(home, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'pglite', chat_model: 'openai:gpt-5' }),
);
function doctorStubEngine(): BrainEngine {
// models.tier.subagent, models.default, agent.use_gateway_loop: all unset.
return { getConfig: async () => null } as unknown as BrainEngine;
}
function setStubEngine(): { engine: BrainEngine; setCalls: Array<[string, string]> } {
const setCalls: Array<[string, string]> = [];
const engine = {
getConfig: async () => null,
setConfig: async (key: string, value: string) => { setCalls.push([key, value]); },
} as unknown as BrainEngine;
return { engine, setCalls };
}
/** Run `runConfig(engine, args)`, capturing console output + exit code
* the way `config-get-plane.test.ts` does for the `get` subcommand. */
async function runConfigCapture(
engine: BrainEngine,
args: string[],
): Promise<{ logs: string[]; errs: string[]; exit: number | null }> {
const logs: string[] = [];
const errs: string[] = [];
let exit: number | null = null;
const logSpy = spyOn(console, 'log').mockImplementation((...a: unknown[]) => { logs.push(a.join(' ')); });
const errSpy = spyOn(console, 'error').mockImplementation((...a: unknown[]) => { errs.push(a.join(' ')); });
const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => {
exit = code ?? 0;
throw new Error(`EXIT:${code}`);
}) as never);
try {
await runConfig(engine, args);
} catch (e) {
if (!(e as Error).message.startsWith('EXIT:')) throw e;
} finally {
logSpy.mockRestore();
errSpy.mockRestore();
exitSpy.mockRestore();
}
return { logs, errs, exit };
}
test('doctor-proposed command round-trips through `config set` without --force', async () => {
const check = await withEnv(
{ GBRAIN_HOME: home, GBRAIN_CHAT_MODEL: undefined, ANTHROPIC_API_KEY: undefined },
() => checkSubagentCapability(doctorStubEngine()),
);
expect(check.status).toBe('warn');
expect(check.message).toContain('agent.use_gateway_loop');
// Pull the exact backtick-quoted command out of the doctor message
// instead of hardcoding it, so the two call sites can't silently drift.
const match = check.message.match(/`(gbrain config set [^`]+)`/);
expect(match).not.toBeNull();
// Tokenize the WHOLE command and feed every argument through, rather than
// destructuring the first two and dropping the rest. If doctor ever starts
// recommending a trailing `--force`, that has to fail here — the entire
// point of #2753 is that the recommended command works without it.
const tokens = match![1].trim().split(/\s+/);
expect(tokens.slice(0, 3)).toEqual(['gbrain', 'config', 'set']);
expect(tokens).not.toContain('--force');
const args = tokens.slice(1); // ['config','set',key,value,...]
expect(args).toEqual(['config', 'set', 'agent.use_gateway_loop', 'true']);
const { engine, setCalls } = setStubEngine();
const { logs, errs, exit } = await runConfigCapture(engine, args.slice(1));
expect(exit).toBeNull();
expect(errs.join('\n')).not.toContain('Unknown config key');
expect(errs.join('\n')).not.toContain('Nothing in gbrain reads this');
expect(setCalls).toEqual([['agent.use_gateway_loop', 'true']]);
expect(logs.join('\n')).toContain('Set agent.use_gateway_loop = true');
});
});
describe('#2753 — doctor and the subagent worker share one truthiness set', () => {
// The doctor accepted true/1/yes/on; the worker accepted only true/1. So
// `gbrain config set agent.use_gateway_loop yes` produced a HEALTHY doctor
// report and a runtime refusal of the exact job the setting enables. Both
// now route through isConfigTruthy, so this asserts the shared contract.
test('accepts the documented on-values, case- and whitespace-insensitively', () => {
for (const v of ['true', '1', 'yes', 'on', 'TRUE', 'Yes', 'ON', ' true ', '\tyes\n']) {
expect(isConfigTruthy(v)).toBe(true);
}
});
test('fails closed on unset, non-string, and anything else', () => {
for (const v of [null, undefined, '', ' ', 'false', '0', 'no', 'off', 'maybe', 1, true, {}]) {
expect(isConfigTruthy(v)).toBe(false);
}
});
});