fix(#2256): pass per-agent model overrides through Codex/OpenCode transport (#2628)

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:
Tom Boucher
2026-04-23 11:58:06 -04:00
committed by GitHub
parent bdba40cc3d
commit 5a8a6fb511
3 changed files with 242 additions and 5 deletions

View File

@@ -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,

View File

@@ -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.

View 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/);
});
});