The Codex and OpenCode install paths read `model_overrides` only from `~/.gsd/defaults.json` (global). A per-project override set in `.planning/config.json` — the reporter's exact setup for `gsd-codebase-mapper` — was silently dropped, so the child agent inherited the runtime's default model regardless of `model_overrides`. Neither runtime has an inline `model` parameter on its spawn API (Codex `spawn_agent(agent_type, message)`, OpenCode `task(description, prompt, subagent_type, task_id, command)`), so the per-agent model must reach the child via the static config GSD writes at install time. That config was being populated from the wrong source. Fix: add `readGsdEffectiveModelOverrides(targetDir)` which merges `~/.gsd/defaults.json` with per-project `.planning/config.json`, with per-project keys winning on conflict. Both install sites now call it and walk up from the install root to locate `.planning/` — matching the precedence `readGsdRuntimeProfileResolver` already uses for #2517. Also update the Codex Task()->spawn_agent mapping block so it no longer says "omit" without context: it now documents that per-agent overrides are embedded in the agent TOML and notes the restriction that Codex only permits `spawn_agent` when the user explicitly requested sub-agents (do the work inline otherwise). Regression tests (`tests/bug-2256-model-overrides-transport.test.cjs`) cover: global-only, project-only, project-wins-on-conflict, walking up from a nested `targetDir`, Codex TOML `model =` emission, and OpenCode frontmatter `model:` emission. Closes #2256 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -634,6 +634,63 @@ function readGsdGlobalModelOverrides() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective per-agent model_overrides for the Codex / OpenCode install paths.
|
||||
*
|
||||
* Merges `~/.gsd/defaults.json` (global) with per-project
|
||||
* `<project>/.planning/config.json`. Per-project keys win on conflict so a
|
||||
* user can tune a single agent's model in one repo without re-setting the
|
||||
* global defaults for every other repo. Non-conflicting keys from both
|
||||
* sources are preserved.
|
||||
*
|
||||
* This is the fix for #2256: both adapters previously read only the global
|
||||
* file, so a per-project `model_overrides` (the common case the reporter
|
||||
* described — a per-project override for `gsd-codebase-mapper` in
|
||||
* `.planning/config.json`) was silently dropped and child agents inherited
|
||||
* the session default.
|
||||
*
|
||||
* `targetDir` is the consuming runtime's install root (e.g. `~/.codex` for
|
||||
* a global install, or `<project>/.codex` for a local install). We walk up
|
||||
* from there looking for `.planning/` so both cases resolve the correct
|
||||
* project root. When `targetDir` is null/undefined only the global file is
|
||||
* consulted (matches prior behavior for code paths that have no project
|
||||
* context).
|
||||
*
|
||||
* Returns a plain `{ agentName: modelId }` object, or `null` when neither
|
||||
* source defines `model_overrides`.
|
||||
*/
|
||||
function readGsdEffectiveModelOverrides(targetDir = null) {
|
||||
const global = readGsdGlobalModelOverrides();
|
||||
|
||||
let projectOverrides = null;
|
||||
if (targetDir) {
|
||||
let probeDir = path.resolve(targetDir);
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
const candidate = path.join(probeDir, '.planning', 'config.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
||||
if (parsed && typeof parsed === 'object' && parsed.model_overrides
|
||||
&& typeof parsed.model_overrides === 'object') {
|
||||
projectOverrides = parsed.model_overrides;
|
||||
}
|
||||
} catch {
|
||||
// Malformed config.json — fall back to global; readGsdRuntimeProfileResolver
|
||||
// surfaces a parse warning via _readGsdConfigFile already.
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(probeDir);
|
||||
if (parent === probeDir) break;
|
||||
probeDir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!global && !projectOverrides) return null;
|
||||
// Per-project wins on conflict; preserve non-conflicting global keys.
|
||||
return { ...(global || {}), ...(projectOverrides || {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2517 — Read a single GSD config file (defaults.json or per-project
|
||||
* config.json) into a plain object, returning null on missing/empty files
|
||||
@@ -1852,9 +1909,17 @@ GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collabo
|
||||
|
||||
Direct mapping:
|
||||
- \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
|
||||
- \`Task(model="...")\` → omit (Codex uses per-role config, not inline model selection)
|
||||
- \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter;
|
||||
GSD embeds the resolved per-agent model directly into each agent's \`.toml\`
|
||||
at install time so \`model_overrides\` from \`.planning/config.json\` and
|
||||
\`~/.gsd/defaults.json\` are honored automatically by Codex's agent router.
|
||||
- \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
|
||||
|
||||
Spawn restriction:
|
||||
- Codex restricts \`spawn_agent\` to cases where the user has explicitly
|
||||
requested sub-agents. When automatic spawning is not permitted, do the
|
||||
work inline in the current agent rather than attempting to force a spawn.
|
||||
|
||||
Parallel fan-out:
|
||||
- Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete
|
||||
|
||||
@@ -3182,12 +3247,15 @@ function installCodexConfig(targetDir, agentsSrc) {
|
||||
|
||||
agents.push({ name, description: toSingleLine(description) });
|
||||
|
||||
// Pass model overrides from ~/.gsd/defaults.json so Codex TOML files
|
||||
// Pass model overrides from both per-project `.planning/config.json` and
|
||||
// `~/.gsd/defaults.json` (project wins on conflict) so Codex TOML files
|
||||
// embed the configured model — Codex cannot receive model inline (#2256).
|
||||
// Previously only the global file was read, which silently dropped the
|
||||
// per-project override the reporter had set for gsd-codebase-mapper.
|
||||
// #2517 — also pass the runtime-aware tier resolver so profile tiers can
|
||||
// resolve to Codex-native model IDs + reasoning_effort when `runtime: "codex"`
|
||||
// is set in defaults.json.
|
||||
const modelOverrides = readGsdGlobalModelOverrides();
|
||||
const modelOverrides = readGsdEffectiveModelOverrides(targetDir);
|
||||
// Pass `targetDir` so per-project .planning/config.json wins over global
|
||||
// ~/.gsd/defaults.json — without this, the PR's headline claim that
|
||||
// setting runtime in the project config reaches the Codex emit path is
|
||||
@@ -5893,9 +5961,13 @@ function install(isGlobal, runtime = 'claude') {
|
||||
content = processAttribution(content, getCommitAttribution(runtime));
|
||||
// Convert frontmatter for runtime compatibility (agents need different handling)
|
||||
if (isOpencode) {
|
||||
// Resolve per-agent model override from ~/.gsd/defaults.json (#2256)
|
||||
// Resolve per-agent model override from BOTH per-project
|
||||
// `.planning/config.json` and `~/.gsd/defaults.json`, with
|
||||
// per-project winning on conflict (#2256). Without the per-project
|
||||
// probe, an override set in `.planning/config.json` was silently
|
||||
// ignored and the child inherited OpenCode's default model.
|
||||
const _ocAgentName = entry.name.replace(/\.md$/, '');
|
||||
const _ocModelOverrides = readGsdGlobalModelOverrides();
|
||||
const _ocModelOverrides = readGsdEffectiveModelOverrides(targetDir);
|
||||
const _ocModelOverride = _ocModelOverrides?.[_ocAgentName] || null;
|
||||
content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
|
||||
} else if (isKilo) {
|
||||
@@ -7071,6 +7143,7 @@ if (process.env.GSD_TEST_MODE) {
|
||||
mergeCodexConfig,
|
||||
installCodexConfig,
|
||||
readGsdRuntimeProfileResolver,
|
||||
readGsdEffectiveModelOverrides,
|
||||
install,
|
||||
uninstall,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
|
||||
@@ -609,6 +609,17 @@ Override specific agents without changing the entire profile:
|
||||
|
||||
Valid override values: `opus`, `sonnet`, `haiku`, `inherit`, or any fully-qualified model ID (e.g., `"openai/o3"`, `"google/gemini-2.5-pro"`).
|
||||
|
||||
`model_overrides` can be set in either `.planning/config.json` (per-project)
|
||||
or `~/.gsd/defaults.json` (global). Per-project entries win on conflict and
|
||||
non-conflicting global entries are preserved, so you can tune a single
|
||||
agent's model in one repo without re-setting global defaults. This applies
|
||||
uniformly across Claude Code, Codex, OpenCode, Kilo, and the other
|
||||
supported runtimes. On Codex and OpenCode, the resolved model is embedded
|
||||
into each agent's static config at install time — `spawn_agent` and
|
||||
OpenCode's `task` interface do not accept an inline `model` parameter, so
|
||||
running `gsd install <runtime>` after editing `model_overrides` is required
|
||||
for the change to take effect. See issue #2256.
|
||||
|
||||
### Non-Claude Runtimes (Codex, OpenCode, Gemini CLI, Kilo)
|
||||
|
||||
When GSD is installed for a non-Claude runtime, the installer automatically sets `resolve_model_ids: "omit"` in `~/.gsd/defaults.json`. This causes GSD to return an empty model parameter for all agents, so each agent uses whatever model the runtime is configured with. No additional setup is needed for the default case.
|
||||
|
||||
153
tests/bug-2256-model-overrides-transport.test.cjs
Normal file
153
tests/bug-2256-model-overrides-transport.test.cjs
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Regression tests for issue #2256 — per-agent model_overrides transport
|
||||
* for Codex and OpenCode runtimes.
|
||||
*
|
||||
* The bug: model_overrides set in per-project `.planning/config.json` were
|
||||
* never read by the Codex / OpenCode install paths, which only probed
|
||||
* `~/.gsd/defaults.json`. As a result, the configured per-agent model was
|
||||
* dropped and child agents inherited the runtime's default model.
|
||||
*
|
||||
* These tests lock in the fix: per-project overrides must be honored, and
|
||||
* per-project keys must win over global when both are present.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
readGsdEffectiveModelOverrides,
|
||||
generateCodexAgentToml,
|
||||
convertClaudeToOpencodeFrontmatter,
|
||||
getCodexSkillAdapterHeader,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
function makeTmp(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2256-${prefix}-`));
|
||||
}
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
function rmr(p) {
|
||||
try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
describe('bug #2256 — readGsdEffectiveModelOverrides', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
rmr(projectDir);
|
||||
rmr(homeDir);
|
||||
});
|
||||
|
||||
test('returns null when neither source defines model_overrides', () => {
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('reads overrides from ~/.gsd/defaults.json (global only)', () => {
|
||||
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' });
|
||||
});
|
||||
|
||||
test('reads overrides from per-project .planning/config.json', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' });
|
||||
});
|
||||
|
||||
test('per-project overrides win over global on conflict', () => {
|
||||
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' },
|
||||
});
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'project-model' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
// Per-project wins on conflict; non-conflicting global keys are preserved.
|
||||
assert.deepStrictEqual(result, {
|
||||
'gsd-codebase-mapper': 'project-model',
|
||||
'gsd-planner': 'opus',
|
||||
});
|
||||
});
|
||||
|
||||
test('walks up from nested targetDir to find .planning/', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-planner': 'project-opus' },
|
||||
});
|
||||
const nested = path.join(projectDir, '.codex');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
const result = readGsdEffectiveModelOverrides(nested);
|
||||
assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — Codex adapter embeds per-project override', () => {
|
||||
const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
|
||||
test('generateCodexAgentToml embeds model when override provided', () => {
|
||||
const toml = generateCodexAgentToml(
|
||||
'gsd-codebase-mapper',
|
||||
agentContent,
|
||||
{ 'gsd-codebase-mapper': 'gpt-5-mini' },
|
||||
);
|
||||
assert.match(toml, /^model = "gpt-5-mini"$/m);
|
||||
});
|
||||
|
||||
test('generateCodexAgentToml omits model when no override', () => {
|
||||
const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null);
|
||||
assert.doesNotMatch(toml, /^model\s*=/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — OpenCode adapter embeds per-project override', () => {
|
||||
test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => {
|
||||
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
const out = convertClaudeToOpencodeFrontmatter(input, {
|
||||
isAgent: true,
|
||||
modelOverride: 'claude-haiku-4-5',
|
||||
});
|
||||
assert.match(out, /^model: claude-haiku-4-5$/m);
|
||||
assert.match(out, /^mode: subagent$/m);
|
||||
});
|
||||
|
||||
test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => {
|
||||
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null });
|
||||
assert.doesNotMatch(out, /^model:/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — Codex skill adapter header documents transport', () => {
|
||||
test('Task(model=...) line no longer says "omit" without explanation', () => {
|
||||
const header = getCodexSkillAdapterHeader('gsd-plan-phase');
|
||||
// Header must mention that per-agent model_overrides are embedded in agent
|
||||
// TOML so spawn_agent picks them up automatically — the old text said
|
||||
// "Codex uses per-role config, not inline model selection" which left
|
||||
// users thinking their model_overrides were silently ignored.
|
||||
assert.match(header, /model_overrides/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user