diff --git a/.changeset/brave-wolves-rally.md b/.changeset/brave-wolves-rally.md new file mode 100644 index 000000000..56c7e50b1 --- /dev/null +++ b/.changeset/brave-wolves-rally.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3253 +--- +**`gsd-sdk query config-set model_overrides.` now accepted** — was rejected with "Unknown config key" despite the override mechanism working. Sibling fix to #3162. diff --git a/get-shit-done/bin/lib/config-schema.cjs b/get-shit-done/bin/lib/config-schema.cjs index bba9d44b0..570de5bd1 100644 --- a/get-shit-done/bin/lib/config-schema.cjs +++ b/get-shit-done/bin/lib/config-schema.cjs @@ -107,6 +107,10 @@ const DYNAMIC_KEY_PATTERNS = [ { topLevel: 'dynamic_routing', test: (k) => /^dynamic_routing\.(enabled|escalate_on_failure|max_escalations|tier_models\.(light|standard|heavy))$/.test(k), description: 'dynamic_routing.>' }, + // #3227 — per-agent model overrides: model_overrides. + // Full model IDs (e.g. "openai/o3") and tier aliases (opus/sonnet/haiku/inherit) + // are both accepted. Value validation is handled by the resolver at read time. + { topLevel: 'model_overrides', test: (k) => /^model_overrides\.[a-zA-Z0-9_-]+$/.test(k), description: 'model_overrides.' }, ]; /** diff --git a/sdk/src/query/config-schema.ts b/sdk/src/query/config-schema.ts index 6788a9ab4..278d444e8 100644 --- a/sdk/src/query/config-schema.ts +++ b/sdk/src/query/config-schema.ts @@ -133,6 +133,12 @@ export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = [ description: 'dynamic_routing.>', test: (k) => /^dynamic_routing\.(enabled|escalate_on_failure|max_escalations|tier_models\.(light|standard|heavy))$/.test(k), }, + // #3227 — per-agent model overrides: model_overrides. + { + source: '^model_overrides\\.[a-zA-Z0-9_-]+$', + description: 'model_overrides.', + test: (k) => /^model_overrides\.[a-zA-Z0-9_-]+$/.test(k), + }, ]; /** Returns true if keyPath is a valid config key (exact, runtime-state, or dynamic pattern). */ diff --git a/tests/bug-3227-config-set-model-overrides.test.cjs b/tests/bug-3227-config-set-model-overrides.test.cjs new file mode 100644 index 000000000..bae347255 --- /dev/null +++ b/tests/bug-3227-config-set-model-overrides.test.cjs @@ -0,0 +1,119 @@ +'use strict'; + +/** + * Regression test for bug #3227 — config-set rejects model_overrides.. + * + * `gsd-sdk query config-set model_overrides.gsd-plan-checker opus` was + * rejected with "Unknown config key" because `model_overrides.` was + * missing from DYNAMIC_KEY_PATTERNS in both the CJS schema and the SDK schema. + * + * The override mechanism itself worked correctly (resolve-model returned the + * override after a direct file edit). Only the write path was gated wrong. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { DYNAMIC_KEY_PATTERNS, isValidConfigKey } = require('../get-shit-done/bin/lib/config-schema.cjs'); + +describe('#3227 — config-set accepts model_overrides.', () => { + test('isValidConfigKey accepts model_overrides.gsd-plan-checker', () => { + assert.ok( + isValidConfigKey('model_overrides.gsd-plan-checker'), + 'model_overrides.gsd-plan-checker must be accepted by isValidConfigKey' + ); + }); + + test('isValidConfigKey accepts model_overrides with various agent-id formats', () => { + const validKeys = [ + 'model_overrides.gsd-executor', + 'model_overrides.gsd-planner', + 'model_overrides.gsd-codebase-mapper', + 'model_overrides.my_custom_agent', + 'model_overrides.agent123', + ]; + for (const key of validKeys) { + assert.ok(isValidConfigKey(key), `isValidConfigKey must accept ${key}`); + } + }); + + test('isValidConfigKey rejects bare model_overrides (no agent-id)', () => { + assert.ok( + !isValidConfigKey('model_overrides'), + 'bare model_overrides must be rejected (use model_overrides.)' + ); + }); + + test('DYNAMIC_KEY_PATTERNS includes an entry for model_overrides', () => { + const hasPattern = DYNAMIC_KEY_PATTERNS.some( + (p) => p.description && p.description.includes('model_overrides') + ); + assert.ok(hasPattern, 'DYNAMIC_KEY_PATTERNS must have an entry covering model_overrides.'); + }); + + test('config-set model_overrides.gsd-plan-checker opus succeeds via gsd-tools.cjs', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools( + ['config-set', 'model_overrides.gsd-plan-checker', 'opus'], + tmpDir + ); + assert.ok( + result.success, + [ + 'config-set model_overrides.gsd-plan-checker opus should succeed,', + 'got:', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set model_overrides.gsd-plan-checker opus writes to config.json', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'model_overrides.gsd-plan-checker', 'opus'], tmpDir); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set'); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.ok( + config.model_overrides !== undefined && + config.model_overrides['gsd-plan-checker'] === 'opus', + [ + 'Expected model_overrides["gsd-plan-checker"]: "opus" in config.json,', + 'got: ' + JSON.stringify(config), + ].join('\n') + ); + }); + + test('config-get model_overrides.gsd-plan-checker returns opus after config-set', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'model_overrides.gsd-plan-checker', 'opus'], tmpDir); + + const getResult = runGsdTools( + ['config-get', 'model_overrides.gsd-plan-checker'], + tmpDir + ); + assert.ok( + getResult.success, + [ + 'config-get model_overrides.gsd-plan-checker should succeed,', + 'got:', + 'stdout: ' + getResult.output, + 'stderr: ' + getResult.error, + ].join('\n') + ); + assert.ok( + getResult.output.includes('opus'), + 'config-get output should contain "opus", got: ' + getResult.output + ); + }); +});