mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type * feat(agent-core): lower default image downscale cap to 2000px and make it configurable * feat(agent-core): strip media to text markers and retry when the compaction request is too large * feat(agent-core): cap model-initiated image reads with a configurable byte budget * feat(agent-core): resend with degraded media when the provider rejects the request body as too large * test(agent-core): add explicit timeouts to encode-heavy image budget tests * feat: add WebP decoding support with wasm integration - Introduced a new WebP decoding module using @jsquash/webp's wasm decoder. - Implemented functions to decode WebP images and check for animated WebP formats. - Updated image compression tests to include scenarios for WebP handling, including encoding and decoding. - Enhanced error handling for API request size limits to accommodate various error messages. - Updated pnpm lockfile to include new dependencies for WebP encoding and decoding. * chore(changeset): consolidate this PR's entries into one * fix(nix): update pnpmDeps hash for merged lockfile * feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance
933 lines
29 KiB
TypeScript
933 lines
29 KiB
TypeScript
import { mkdtempSync } from 'node:fs';
|
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'pathe';
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { ErrorCodes, KimiError } from '../../src/errors';
|
|
import {
|
|
KimiConfigSchema,
|
|
configToTomlData,
|
|
ensureConfigFile,
|
|
loadRuntimeConfig,
|
|
loadRuntimeConfigSafe,
|
|
mergeConfigPatch,
|
|
parseConfigString,
|
|
parseBooleanEnv,
|
|
readConfigFile,
|
|
readConfigFileForUpdate,
|
|
resolveConfigPath,
|
|
resolveConfigValue,
|
|
resolveKimiHome,
|
|
validateConfig,
|
|
writeConfigFile,
|
|
} from '../../src/config';
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeTempDir(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'kimi-core-config-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function expectKimiErrorCode(fn: () => unknown, code: string): void {
|
|
try {
|
|
fn();
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(KimiError);
|
|
expect((error as KimiError).code).toBe(code);
|
|
return;
|
|
}
|
|
throw new Error('expected function to throw');
|
|
}
|
|
|
|
const COMPLETE_TOML = `
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
default_permission_mode = "auto"
|
|
default_plan_mode = false
|
|
merge_all_available_skills = true
|
|
extra_skill_dirs = ["~/team-skills", ".agents/team-skills"]
|
|
telemetry = false
|
|
theme = "dark"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "https://api.kimi.com/coding/v1"
|
|
api_key = "sk-file"
|
|
custom_headers = { "X-Test" = "1" }
|
|
|
|
[providers."managed:kimi-code".env]
|
|
GOOGLE_CLOUD_PROJECT = "project-1"
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 262144
|
|
capabilities = ["image_in", "thinking", "video_in"]
|
|
display_name = "Kimi for Coding"
|
|
|
|
[thinking]
|
|
enabled = true
|
|
effort = "medium"
|
|
|
|
[permission]
|
|
mode = "manual"
|
|
|
|
[[permission.rules]]
|
|
decision = "deny"
|
|
scope = "user"
|
|
pattern = "Bash(rm *)"
|
|
reason = "no rm"
|
|
|
|
[[permission.allow]]
|
|
tool = "Read"
|
|
match = "src/**"
|
|
reason = "read src"
|
|
|
|
[loop_control]
|
|
max_steps_per_run = 42
|
|
max_retries_per_step = 3
|
|
reserved_context_size = 50000
|
|
compaction_trigger_ratio = 0.85
|
|
|
|
[background]
|
|
max_running_tasks = 4
|
|
keep_alive_on_exit = false
|
|
kill_grace_period_ms = 2000
|
|
print_wait_ceiling_s = 3600
|
|
|
|
[image]
|
|
max_edge_px = 1500
|
|
read_byte_budget = 131072
|
|
|
|
[[hooks]]
|
|
event = "PreToolUse"
|
|
matcher = "Shell"
|
|
command = "echo pre"
|
|
timeout = 5
|
|
|
|
[[hooks]]
|
|
event = "Stop"
|
|
command = "echo stop"
|
|
|
|
[services.moonshot_search]
|
|
base_url = "https://api.kimi.com/coding/v1/search"
|
|
api_key = "sk-search"
|
|
custom_headers = { "X-Search" = "1" }
|
|
|
|
[services.moonshot_fetch]
|
|
base_url = "https://api.kimi.com/coding/v1/fetch"
|
|
api_key = "sk-fetch"
|
|
|
|
[notifications]
|
|
claim_stale_after_ms = 15000
|
|
`;
|
|
|
|
describe('harness config TOML loader', () => {
|
|
it('parses the current config.toml shape through explicit field mappings', () => {
|
|
const config = parseConfigString(COMPLETE_TOML, 'config.toml');
|
|
|
|
expect(config.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(config.thinking?.enabled).toBe(true);
|
|
expect(config.defaultPermissionMode).toBe('auto');
|
|
expect(config.defaultPlanMode).toBe(false);
|
|
expect(config.mergeAllAvailableSkills).toBe(true);
|
|
expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']);
|
|
expect(config.telemetry).toBe(false);
|
|
expect(config.providers['managed:kimi-code']).toMatchObject({
|
|
type: 'kimi',
|
|
baseUrl: 'https://api.kimi.com/coding/v1',
|
|
apiKey: 'sk-file',
|
|
env: { GOOGLE_CLOUD_PROJECT: 'project-1' },
|
|
customHeaders: { 'X-Test': '1' },
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toMatchObject({
|
|
provider: 'managed:kimi-code',
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 262144,
|
|
capabilities: ['image_in', 'thinking', 'video_in'],
|
|
displayName: 'Kimi for Coding',
|
|
});
|
|
expect(config.thinking).toEqual({ enabled: true, effort: 'medium' });
|
|
expect(config.permission).toEqual({
|
|
rules: [
|
|
{
|
|
decision: 'deny',
|
|
scope: 'user',
|
|
pattern: 'Bash(rm *)',
|
|
reason: 'no rm',
|
|
},
|
|
{
|
|
decision: 'allow',
|
|
scope: 'user',
|
|
pattern: 'Read(src/**)',
|
|
reason: 'read src',
|
|
},
|
|
],
|
|
});
|
|
expect(config.loopControl).toMatchObject({
|
|
maxStepsPerTurn: 42,
|
|
maxRetriesPerStep: 3,
|
|
reservedContextSize: 50000,
|
|
compactionTriggerRatio: 0.85,
|
|
});
|
|
expect(config.background).toMatchObject({
|
|
maxRunningTasks: 4,
|
|
keepAliveOnExit: false,
|
|
killGracePeriodMs: 2000,
|
|
printWaitCeilingS: 3600,
|
|
});
|
|
expect(config.image).toEqual({ maxEdgePx: 1500, readByteBudget: 131072 });
|
|
expect(config.hooks).toEqual([
|
|
{
|
|
event: 'PreToolUse',
|
|
matcher: 'Shell',
|
|
command: 'echo pre',
|
|
timeout: 5,
|
|
},
|
|
{
|
|
event: 'Stop',
|
|
command: 'echo stop',
|
|
},
|
|
]);
|
|
expect(config.services?.moonshotSearch?.customHeaders).toEqual({ 'X-Search': '1' });
|
|
expect(config.services?.moonshotFetch?.apiKey).toBe('sk-fetch');
|
|
|
|
expect('theme' in config).toBe(false);
|
|
expect(config.raw?.['theme']).toBe('dark');
|
|
expect(config.raw?.['notifications']).toEqual({ claim_stale_after_ms: 15000 });
|
|
});
|
|
|
|
it('round-trips the [image] section', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'image-round-trip.toml');
|
|
const toml = `
|
|
[image]
|
|
max_edge_px = 2500
|
|
read_byte_budget = 524288
|
|
`;
|
|
const config = parseConfigString(toml, configPath);
|
|
expect(config.image).toEqual({ maxEdgePx: 2500, readByteBudget: 524288 });
|
|
|
|
await writeConfigFile(configPath, config);
|
|
const text = await readFile(configPath, 'utf-8');
|
|
const roundTripped = parseConfigString(text, configPath);
|
|
expect(roundTripped.image).toEqual({ maxEdgePx: 2500, readByteBudget: 524288 });
|
|
});
|
|
|
|
it('round-trips a custom registry source field on a provider', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'round-trip.toml');
|
|
const toml = `
|
|
[providers.custom]
|
|
type = "openai"
|
|
base_url = "https://custom.example/v1"
|
|
api_key = "sk-test"
|
|
source = { kind = "apiJson", url = "https://registry.example/api.json", apiKey = "sk-registry" }
|
|
`;
|
|
const config = parseConfigString(toml, configPath);
|
|
expect(config.providers['custom']).toMatchObject({
|
|
type: 'openai',
|
|
baseUrl: 'https://custom.example/v1',
|
|
apiKey: 'sk-test',
|
|
source: { kind: 'apiJson', url: 'https://registry.example/api.json', apiKey: 'sk-registry' },
|
|
});
|
|
|
|
await writeConfigFile(configPath, config);
|
|
const text = await readFile(configPath, 'utf-8');
|
|
const roundTripped = parseConfigString(text, configPath);
|
|
expect(roundTripped.providers['custom']?.source).toEqual({
|
|
kind: 'apiJson',
|
|
url: 'https://registry.example/api.json',
|
|
apiKey: 'sk-registry',
|
|
});
|
|
});
|
|
|
|
it('round-trips OAuth refs with scoped OAuth hosts', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'oauth-ref.toml');
|
|
const toml = `
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "https://api.dev.example.test/coding/v1"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" }
|
|
|
|
[services.moonshot_search]
|
|
base_url = "https://api.dev.example.test/coding/v1/search"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" }
|
|
`;
|
|
const config = parseConfigString(toml, configPath);
|
|
expect(config.providers['managed:kimi-code']?.oauth).toEqual({
|
|
storage: 'file',
|
|
key: 'oauth/kimi-code-env-1234',
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
});
|
|
expect(config.services?.moonshotSearch?.oauth?.oauthHost).toBe('https://auth.dev.example.test');
|
|
|
|
await writeConfigFile(configPath, config);
|
|
const text = await readFile(configPath, 'utf-8');
|
|
expect(text).toContain('oauth_host = "https://auth.dev.example.test"');
|
|
const roundTripped = parseConfigString(text, configPath);
|
|
expect(roundTripped.providers['managed:kimi-code']?.oauth?.oauthHost).toBe(
|
|
'https://auth.dev.example.test',
|
|
);
|
|
});
|
|
|
|
it('parses and round-trips experimental feature flags', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'experimental.toml');
|
|
const toml = `
|
|
[experimental]
|
|
micro_compaction = false
|
|
`;
|
|
const config = parseConfigString(toml, configPath);
|
|
|
|
expect(config.experimental).toEqual({
|
|
'micro_compaction': false,
|
|
});
|
|
|
|
await writeConfigFile(configPath, config);
|
|
const text = await readFile(configPath, 'utf-8');
|
|
|
|
expect(text).toContain('[experimental]');
|
|
expect(text).toContain('micro_compaction = false');
|
|
expect(parseConfigString(text, configPath).experimental).toEqual(config.experimental);
|
|
});
|
|
|
|
it('accepts obsolete experimental feature keys as inert config', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'obsolete-experimental.toml');
|
|
const toml = `
|
|
[experimental]
|
|
legacy_feature = true
|
|
obsolete_feature = false
|
|
removed_flag = true
|
|
`;
|
|
|
|
const config = parseConfigString(toml, configPath);
|
|
|
|
expect(config.experimental).toEqual({
|
|
'legacy_feature': true,
|
|
'obsolete_feature': false,
|
|
'removed_flag': true,
|
|
});
|
|
|
|
await writeConfigFile(configPath, config);
|
|
const text = await readFile(configPath, 'utf-8');
|
|
expect(parseConfigString(text, configPath).experimental).toEqual(config.experimental);
|
|
});
|
|
|
|
it('loads defaults for absent files and writes typed fields without dropping raw sections', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'config.toml');
|
|
|
|
expect(readConfigFile(configPath)).toEqual({ providers: {} });
|
|
|
|
const config = parseConfigString(COMPLETE_TOML, configPath);
|
|
const loopControl = config.loopControl;
|
|
expect(loopControl).toBeDefined();
|
|
await writeConfigFile(configPath, {
|
|
...config,
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
loopControl: {
|
|
...loopControl!,
|
|
maxStepsPerTurn: 7,
|
|
},
|
|
});
|
|
|
|
const text = await readFile(configPath, 'utf-8');
|
|
expect(text).toContain('default_model = "kimi-code/kimi-for-coding"');
|
|
expect(text).toContain('default_permission_mode = "auto"');
|
|
expect(text).toContain('extra_skill_dirs = [ "~/team-skills", ".agents/team-skills" ]');
|
|
expect(text).toContain('telemetry = false');
|
|
expect(text).not.toContain('default_yolo');
|
|
expect(text).toContain('[[permission.rules]]');
|
|
expect(text).toContain('pattern = "Bash(rm *)"');
|
|
expect(text).toContain('pattern = "Read(src/**)"');
|
|
expect(text).not.toContain('[[permission.allow]]');
|
|
expect(text).toContain('max_steps_per_turn = 7');
|
|
expect(text).toContain('GOOGLE_CLOUD_PROJECT = "project-1"');
|
|
expect(text).toContain('theme = "dark"');
|
|
expect(text).toContain('claim_stale_after_ms = 15000');
|
|
expect(text).toContain('[[hooks]]');
|
|
expect(text).toContain('event = "PreToolUse"');
|
|
expect(text).toContain('command = "echo pre"');
|
|
|
|
const reloaded = readConfigFile(configPath);
|
|
expect(reloaded.loopControl?.maxStepsPerTurn).toBe(7);
|
|
expect(reloaded.hooks?.[0]?.event).toBe('PreToolUse');
|
|
expect(reloaded.raw?.['theme']).toBe('dark');
|
|
});
|
|
|
|
it('creates a parseable default config scaffold without changing runtime defaults', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'config.toml');
|
|
|
|
await ensureConfigFile(configPath);
|
|
|
|
const text = await readFile(configPath, 'utf-8');
|
|
expect(text).toContain('Runtime settings for Kimi Code.');
|
|
expect(text).not.toMatch(/^default_thinking =/m);
|
|
expect(text).not.toMatch(/^default_model =/m);
|
|
|
|
const config = readConfigFile(configPath);
|
|
expect(config.providers).toEqual({});
|
|
expect(config.defaultModel).toBeUndefined();
|
|
expect(config.thinking?.enabled).toBeUndefined();
|
|
});
|
|
|
|
it('does not overwrite an existing config file', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'config.toml');
|
|
const existing = 'default_model = "custom"\n';
|
|
await writeFile(configPath, existing, 'utf-8');
|
|
|
|
await ensureConfigFile(configPath);
|
|
|
|
await expect(readFile(configPath, 'utf-8')).resolves.toBe(existing);
|
|
});
|
|
|
|
it('drops deprecated default_yolo when rewriting config files', async () => {
|
|
const dir = makeTempDir();
|
|
const configPath = join(dir, 'config.toml');
|
|
const config = parseConfigString('default_yolo = true\n', configPath);
|
|
|
|
expect(config.defaultPermissionMode).toBeUndefined();
|
|
|
|
await writeConfigFile(configPath, config);
|
|
|
|
const text = await readFile(configPath, 'utf-8');
|
|
expect(text).not.toContain('default_yolo');
|
|
expect(text).not.toContain('default_permission_mode');
|
|
});
|
|
|
|
it('rejects invalid TOML and invalid schema with KimiError(config.invalid)', () => {
|
|
expectKimiErrorCode(
|
|
() => parseConfigString('[[[', 'broken.toml'),
|
|
ErrorCodes.CONFIG_INVALID,
|
|
);
|
|
expectKimiErrorCode(
|
|
() =>
|
|
parseConfigString(
|
|
`
|
|
[providers.bad]
|
|
type = "not-a-provider"
|
|
`,
|
|
'broken.toml',
|
|
),
|
|
ErrorCodes.CONFIG_INVALID,
|
|
);
|
|
expectKimiErrorCode(
|
|
() =>
|
|
parseConfigString(
|
|
`
|
|
[[permission.rules]]
|
|
decision = "deny"
|
|
pattern = "Bash(rm *"
|
|
`,
|
|
'broken.toml',
|
|
),
|
|
ErrorCodes.CONFIG_INVALID,
|
|
);
|
|
});
|
|
|
|
it('parses hooks config from TOML arrays of tables', () => {
|
|
const config = parseConfigString(
|
|
`
|
|
[[hooks]]
|
|
event = "PreToolUse"
|
|
matcher = "Shell"
|
|
command = "echo hi"
|
|
timeout = 5
|
|
`,
|
|
'hooks.toml',
|
|
);
|
|
|
|
expect(config.hooks).toEqual([
|
|
{
|
|
event: 'PreToolUse',
|
|
matcher: 'Shell',
|
|
command: 'echo hi',
|
|
timeout: 5,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('rejects invalid hooks config', () => {
|
|
expectKimiErrorCode(
|
|
() =>
|
|
parseConfigString(
|
|
`
|
|
hooks = [{ type = "pre-tool-call", command = "echo hi" }]
|
|
`,
|
|
'hooks.toml',
|
|
),
|
|
ErrorCodes.CONFIG_INVALID,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('harness config schema and patch merge', () => {
|
|
it('accepts the empty public config and requires model context size in full configs', () => {
|
|
expect(KimiConfigSchema.parse({})).toEqual({ providers: {} });
|
|
expect(() =>
|
|
validateConfig({
|
|
providers: {
|
|
local: { type: 'openai', apiKey: 'sk-test' },
|
|
},
|
|
models: {
|
|
broken: { provider: 'local', model: 'gpt-test' },
|
|
},
|
|
}),
|
|
).toThrow(/max_context_size/);
|
|
});
|
|
|
|
it('deep-merges validated patches while preserving existing typed and raw data', () => {
|
|
const base = parseConfigString(COMPLETE_TOML);
|
|
const merged = mergeConfigPatch(base, {
|
|
providers: {
|
|
'managed:kimi-code': {
|
|
apiKey: 'sk-patched',
|
|
baseUrl: undefined,
|
|
},
|
|
},
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
capabilities: ['tool_use'],
|
|
},
|
|
},
|
|
thinking: {
|
|
effort: 'high',
|
|
},
|
|
});
|
|
|
|
expect(merged.providers['managed:kimi-code']).toMatchObject({
|
|
type: 'kimi',
|
|
baseUrl: 'https://api.kimi.com/coding/v1',
|
|
apiKey: 'sk-patched',
|
|
env: { GOOGLE_CLOUD_PROJECT: 'project-1' },
|
|
});
|
|
expect(merged.models?.['kimi-code/kimi-for-coding']).toMatchObject({
|
|
provider: 'managed:kimi-code',
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 262144,
|
|
capabilities: ['tool_use'],
|
|
});
|
|
expect(merged.thinking).toEqual({ enabled: true, effort: 'high' });
|
|
expect(merged.hooks).toEqual(base.hooks);
|
|
expect(merged.raw?.['theme']).toBe('dark');
|
|
});
|
|
|
|
it('deep-merges experimental config patches', () => {
|
|
const base = parseConfigString(`
|
|
[experimental]
|
|
micro_compaction = false
|
|
`);
|
|
|
|
const merged = mergeConfigPatch(base, {
|
|
experimental: {
|
|
'micro_compaction': true,
|
|
},
|
|
});
|
|
|
|
expect(merged.experimental).toEqual({
|
|
'micro_compaction': true,
|
|
});
|
|
});
|
|
|
|
it('rejects unknown fields in config patches', () => {
|
|
expectKimiErrorCode(
|
|
() => mergeConfigPatch({ providers: {} }, { theme: 'dark' } as never),
|
|
ErrorCodes.CONFIG_INVALID,
|
|
);
|
|
});
|
|
|
|
it('replaces hooks arrays in config patches', () => {
|
|
const base = parseConfigString(COMPLETE_TOML);
|
|
const merged = mergeConfigPatch(base, {
|
|
hooks: [{ event: 'Notification', matcher: 'task_completed', command: 'echo notified' }],
|
|
});
|
|
|
|
expect(merged.hooks).toEqual([
|
|
{ event: 'Notification', matcher: 'task_completed', command: 'echo notified' },
|
|
]);
|
|
});
|
|
|
|
it('accepts maxOutputSize on a model alias and round-trips it', () => {
|
|
const parsed = KimiConfigSchema.parse({
|
|
providers: { local: { type: 'anthropic', apiKey: 'sk-test' } },
|
|
models: {
|
|
opus: {
|
|
provider: 'local',
|
|
model: 'claude-opus-4-7',
|
|
maxContextSize: 200000,
|
|
maxOutputSize: 32000,
|
|
},
|
|
},
|
|
});
|
|
expect(parsed.models?.['opus']).toMatchObject({
|
|
maxContextSize: 200000,
|
|
maxOutputSize: 32000,
|
|
});
|
|
});
|
|
|
|
it('leaves maxOutputSize undefined when omitted', () => {
|
|
const parsed = KimiConfigSchema.parse({
|
|
providers: { local: { type: 'anthropic', apiKey: 'sk-test' } },
|
|
models: {
|
|
opus: {
|
|
provider: 'local',
|
|
model: 'claude-opus-4-7',
|
|
maxContextSize: 200000,
|
|
},
|
|
},
|
|
});
|
|
expect(parsed.models?.['opus']?.maxOutputSize).toBeUndefined();
|
|
});
|
|
|
|
it('rejects maxOutputSize <= 0', () => {
|
|
expect(() =>
|
|
KimiConfigSchema.parse({
|
|
providers: { local: { type: 'anthropic', apiKey: 'sk-test' } },
|
|
models: {
|
|
opus: {
|
|
provider: 'local',
|
|
model: 'claude-opus-4-7',
|
|
maxContextSize: 200000,
|
|
maxOutputSize: 0,
|
|
},
|
|
},
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('config path env override', () => {
|
|
it('uses KIMI_CODE_HOME when no explicit homeDir is supplied', () => {
|
|
const saved = process.env['KIMI_CODE_HOME'];
|
|
try {
|
|
process.env['KIMI_CODE_HOME'] = '/tmp/kimi-from-env';
|
|
|
|
expect(resolveKimiHome()).toBe('/tmp/kimi-from-env');
|
|
expect(resolveKimiHome('/tmp/kimi-explicit')).toBe('/tmp/kimi-explicit');
|
|
expect(resolveConfigPath({})).toBe('/tmp/kimi-from-env/config.toml');
|
|
expect(resolveConfigPath({ configPath: '/tmp/custom.toml' })).toBe('/tmp/custom.toml');
|
|
} finally {
|
|
if (saved === undefined) delete process.env['KIMI_CODE_HOME'];
|
|
else process.env['KIMI_CODE_HOME'] = saved;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('config value env override helpers', () => {
|
|
it('parses boolean env values', () => {
|
|
expect(parseBooleanEnv('1')).toBe(true);
|
|
expect(parseBooleanEnv(' true ')).toBe(true);
|
|
expect(parseBooleanEnv('yes')).toBe(true);
|
|
expect(parseBooleanEnv('on')).toBe(true);
|
|
expect(parseBooleanEnv('0')).toBe(false);
|
|
expect(parseBooleanEnv(' false ')).toBe(false);
|
|
expect(parseBooleanEnv('no')).toBe(false);
|
|
expect(parseBooleanEnv('off')).toBe(false);
|
|
expect(parseBooleanEnv('')).toBeUndefined();
|
|
expect(parseBooleanEnv('maybe')).toBeUndefined();
|
|
});
|
|
|
|
it('resolves env before config before default', () => {
|
|
expect(
|
|
resolveConfigValue({
|
|
env: { KIMI_TEST_FLAG: '0' },
|
|
envKey: 'KIMI_TEST_FLAG',
|
|
configValue: true,
|
|
defaultValue: true,
|
|
parseEnv: parseBooleanEnv,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
resolveConfigValue({
|
|
env: {},
|
|
envKey: 'KIMI_TEST_FLAG',
|
|
configValue: false,
|
|
defaultValue: true,
|
|
parseEnv: parseBooleanEnv,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
resolveConfigValue({
|
|
env: {},
|
|
envKey: 'KIMI_TEST_FLAG',
|
|
defaultValue: true,
|
|
parseEnv: parseBooleanEnv,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('ignores invalid env values', () => {
|
|
expect(
|
|
resolveConfigValue({
|
|
env: { KIMI_TEST_FLAG: 'invalid' },
|
|
envKey: 'KIMI_TEST_FLAG',
|
|
configValue: false,
|
|
defaultValue: true,
|
|
parseEnv: parseBooleanEnv,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadRuntimeConfigSafe', () => {
|
|
const VALID_TOML = `
|
|
default_model = "k2"
|
|
|
|
[providers.kimi]
|
|
type = "kimi"
|
|
api_key = "sk-good"
|
|
|
|
[models.k2]
|
|
provider = "kimi"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 128000
|
|
`;
|
|
|
|
async function writeTempConfig(text: string): Promise<string> {
|
|
const configPath = join(makeTempDir(), 'config.toml');
|
|
await writeFile(configPath, text, 'utf-8');
|
|
return configPath;
|
|
}
|
|
|
|
it('loads a valid file with no warnings, matching the strict loader', async () => {
|
|
const configPath = await writeTempConfig(VALID_TOML);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.fileWarnings).toEqual([]);
|
|
expect(result.envWarnings).toEqual([]);
|
|
expect(result.config).toEqual(loadRuntimeConfig(configPath, {}));
|
|
});
|
|
|
|
it('returns defaults with no warnings when the file is missing', () => {
|
|
const configPath = join(makeTempDir(), 'config.toml');
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.fileWarnings).toEqual([]);
|
|
expect(result.envWarnings).toEqual([]);
|
|
expect(result.config.providers).toEqual({});
|
|
});
|
|
|
|
it('reports a fileError and defaults on invalid TOML syntax', async () => {
|
|
const configPath = await writeTempConfig('[[[');
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.providers).toEqual({});
|
|
// The whole file is unusable: callers decide to fail startup (fileError)
|
|
// or keep the last good config mid-run (fileWarnings).
|
|
expect(result.fileError).toBeInstanceOf(KimiError);
|
|
expect(result.fileError?.code).toBe(ErrorCodes.CONFIG_INVALID);
|
|
expect(result.fileError?.message).toContain('Invalid TOML');
|
|
expect(result.fileError?.message).toContain(configPath);
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
const warning = result.fileWarnings[0]!;
|
|
expect(warning).toContain('Invalid TOML');
|
|
// Single-line summary with the error location, not the multi-line code frame.
|
|
expect(warning).not.toContain('\n');
|
|
expect(warning).toContain('line 1');
|
|
});
|
|
|
|
it('does not set fileError when only sections are dropped', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[loop_control]
|
|
max_steps_per_turn = "nope"
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.fileError).toBeUndefined();
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
});
|
|
|
|
it('drops only an invalid section on schema errors and keeps the rest', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[loop_control]
|
|
max_steps_per_turn = "not-a-number"
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.loopControl).toBeUndefined();
|
|
expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi', apiKey: 'sk-good' });
|
|
expect(result.config.models?.['k2']).toMatchObject({ maxContextSize: 128000 });
|
|
expect(result.config.defaultModel).toBe('k2');
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
expect(result.fileWarnings[0]).toContain('loop_control');
|
|
// The original file content stays visible in raw so nothing is lost.
|
|
expect(result.config.raw?.['loop_control']).toEqual({ max_steps_per_turn: 'not-a-number' });
|
|
});
|
|
|
|
it('drops only the broken provider entry, keeping other providers', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[providers.bad]
|
|
type = "not-a-provider"
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.providers['bad']).toBeUndefined();
|
|
expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi' });
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
expect(result.fileWarnings[0]).toContain('providers.bad');
|
|
});
|
|
|
|
it('keeps other providers when one entry has multiple validation issues', async () => {
|
|
// Two issues on the same entry: the second must not escalate to
|
|
// deleting the whole providers section after the first dropped the entry.
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[providers.bad]
|
|
type = "not-a-provider"
|
|
api_key = 123
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.providers['bad']).toBeUndefined();
|
|
expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi' });
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
expect(result.fileWarnings[0]).toContain('providers.bad');
|
|
expect(result.fileWarnings[0]).not.toMatch(/providers[,.]? /);
|
|
});
|
|
|
|
it('drops only the broken model entry', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[models.broken]
|
|
provider = "kimi"
|
|
model = "x"
|
|
max_context_size = -5
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.models?.['broken']).toBeUndefined();
|
|
expect(result.config.models?.['k2']).toBeDefined();
|
|
expect(result.fileWarnings[0]).toContain('models.broken');
|
|
});
|
|
|
|
it('drops the whole hooks list when one hook is invalid', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[[hooks]]
|
|
event = "NotARealEvent"
|
|
command = "echo hi"
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.hooks).toBeUndefined();
|
|
expect(result.config.providers['kimi']).toBeDefined();
|
|
expect(result.fileWarnings[0]).toContain('hooks');
|
|
});
|
|
|
|
it('reports every dropped section in the warning', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[loop_control]
|
|
max_steps_per_turn = "nope"
|
|
|
|
[background]
|
|
max_running_tasks = 0
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.loopControl).toBeUndefined();
|
|
expect(result.config.background).toBeUndefined();
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
expect(result.fileWarnings[0]).toContain('loop_control');
|
|
expect(result.fileWarnings[0]).toContain('background');
|
|
});
|
|
|
|
it('applies KIMI_MODEL_* env overrides on top of a salvaged config', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[loop_control]
|
|
max_steps_per_turn = "nope"
|
|
`);
|
|
const result = loadRuntimeConfigSafe(configPath, {
|
|
KIMI_MODEL_NAME: 'env-model',
|
|
KIMI_MODEL_API_KEY: 'sk-env',
|
|
KIMI_MODEL_MAX_CONTEXT_SIZE: '262144',
|
|
});
|
|
expect(result.envWarnings).toEqual([]);
|
|
expect(result.config.models?.['__kimi_env_model__']).toBeDefined();
|
|
expect(result.config.providers['kimi']).toBeDefined();
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
});
|
|
|
|
it('skips KIMI_MODEL_* overrides with an env warning instead of throwing', async () => {
|
|
const configPath = await writeTempConfig(VALID_TOML);
|
|
const result = loadRuntimeConfigSafe(configPath, {
|
|
KIMI_MODEL_NAME: 'env-model',
|
|
});
|
|
expect(result.fileWarnings).toEqual([]);
|
|
expect(result.envWarnings).toHaveLength(1);
|
|
expect(result.envWarnings[0]).toContain('KIMI_MODEL');
|
|
expect(result.config).toEqual(readConfigFile(configPath));
|
|
});
|
|
|
|
it('readConfigFileForUpdate rewraps validation errors with an actionable message', async () => {
|
|
const configPath = await writeTempConfig(`${VALID_TOML}
|
|
[loop_control]
|
|
max_steps_per_turn = "nope"
|
|
`);
|
|
try {
|
|
readConfigFileForUpdate(configPath);
|
|
throw new Error('expected readConfigFileForUpdate to throw');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(KimiError);
|
|
expect((error as KimiError).message).toContain('fix it first');
|
|
expect((error as KimiError).message).toContain('kimi doctor');
|
|
expect((error as KimiError).message).not.toContain('invalid_type');
|
|
}
|
|
|
|
const goodPath = await writeTempConfig(VALID_TOML);
|
|
expect(readConfigFileForUpdate(goodPath)).toEqual(readConfigFile(goodPath));
|
|
});
|
|
|
|
it('drops invalid top-level scalars and keeps the rest', async () => {
|
|
const configPath = await writeTempConfig(`default_permission_mode = "not-a-mode"
|
|
${VALID_TOML}`);
|
|
const result = loadRuntimeConfigSafe(configPath, {});
|
|
expect(result.config.defaultPermissionMode).toBeUndefined();
|
|
expect(result.config.providers['kimi']).toBeDefined();
|
|
expect(result.fileWarnings).toHaveLength(1);
|
|
expect(result.fileWarnings[0]).toContain('default_permission_mode');
|
|
});
|
|
});
|
|
|
|
describe('model overrides TOML', () => {
|
|
it('parses nested model overrides from snake_case TOML', () => {
|
|
const config = parseConfigString(`
|
|
[models."kimi-code/kimi-k2"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-k2"
|
|
max_context_size = 262144
|
|
support_efforts = ["low", "high", "max"]
|
|
|
|
[models."kimi-code/kimi-k2".overrides]
|
|
support_efforts = ["low", "high"]
|
|
default_effort = "high"
|
|
`);
|
|
|
|
expect(config.models?.['kimi-code/kimi-k2']?.overrides).toEqual({
|
|
supportEfforts: ['low', 'high'],
|
|
defaultEffort: 'high',
|
|
});
|
|
});
|
|
|
|
it('writes nested model overrides back as snake_case TOML data', () => {
|
|
const config = parseConfigString(`
|
|
[models."kimi-code/kimi-k2"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-k2"
|
|
max_context_size = 262144
|
|
|
|
[models."kimi-code/kimi-k2".overrides]
|
|
support_efforts = ["low", "high"]
|
|
`);
|
|
|
|
const data = configToTomlData(config);
|
|
const models = data['models'] as Record<string, Record<string, unknown>>;
|
|
const overrides = models['kimi-code/kimi-k2']?.['overrides'] as Record<string, unknown>;
|
|
|
|
expect(overrides['support_efforts']).toEqual(['low', 'high']);
|
|
});
|
|
});
|