diff --git a/.changeset/3643-resolve-model-claude-runtime.md b/.changeset/3643-resolve-model-claude-runtime.md new file mode 100644 index 000000000..0aa2238db --- /dev/null +++ b/.changeset/3643-resolve-model-claude-runtime.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3648 +--- +**`gsd-sdk query resolve-model` now honors `resolve_model_ids: true` under `runtime: "claude"`** — previously the resolver bailed out of `resolveRuntimeTier` for Claude (because Claude is the implicit/default runtime in `config-query.ts:149`) and fell through to the alias-return path on line 243 without consulting the catalog. Consumers asking for resolved model IDs received the tier alias (`opus` / `sonnet` / `haiku`) instead of the full Claude model ID (`claude-opus-4-7` / `claude-sonnet-4-6` / `claude-haiku-4-5`). The CJS branch at `get-shit-done/bin/lib/core.cjs:1348-1350` (`if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;`) was missing from the TS port. Added a `runtime === 'claude' && resolveModelIds === true` branch that calls `resolveRuntimeTierDefault('claude', tier)` from the shared model-catalog so both runtimes derive Claude IDs from the same source of truth. `model_overrides`, the phase-type tier override (`config.models[phaseType]`), and `resolve_model_ids: "omit"` all retain their existing precedence. (#3643) diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index 5faa8ede6..9722861d0 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -259,6 +259,111 @@ describe('resolveModel', () => { expect(planner).not.toHaveProperty('reasoning_effort'); }); + // ─── #3643: runtime:claude + resolve_model_ids:true must return full IDs ── + // Symptom: aliases (opus/sonnet/haiku) leaked through to consumers that asked + // for resolved model IDs because resolveRuntimeTier bails for runtime:claude + // and the alias-return fall-through ignored resolve_model_ids. CJS branch at + // get-shit-done/bin/lib/core.cjs:1348-1350 has the missing guard. + it('#3643: runtime:claude + resolve_model_ids:true + balanced returns full sonnet id', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + runtime: 'claude', + resolve_model_ids: true, + }), + ); + const result = await resolveModel(['gsd-executor'], tmpDir); + expect(result.data).toEqual({ model: 'claude-sonnet-4-6', profile: 'balanced' }); + }); + + it('#3643: runtime:claude + resolve_model_ids:true + quality returns full opus id', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'quality', + runtime: 'claude', + resolve_model_ids: true, + }), + ); + const result = await resolveModel(['gsd-planner'], tmpDir); + expect(result.data).toEqual({ model: 'claude-opus-4-7', profile: 'quality' }); + }); + + it('#3643: runtime:claude + resolve_model_ids:true + budget returns full haiku id', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'budget', + runtime: 'claude', + resolve_model_ids: true, + }), + ); + // gsd-verifier maps to 'haiku' under budget profile per model-catalog.json. + const result = await resolveModel(['gsd-verifier'], tmpDir); + expect(result.data).toEqual({ model: 'claude-haiku-4-5', profile: 'budget' }); + }); + + it('#3643: phase-type tier override (models.execution=opus) wins under claude+resolve_model_ids', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'budget', + runtime: 'claude', + resolve_model_ids: true, + models: { execution: 'opus' }, + }), + ); + const result = await resolveModel(['gsd-executor'], tmpDir); + expect(result.data).toEqual({ model: 'claude-opus-4-7', profile: 'budget' }); + }); + + it('#3643 regression-guard: runtime:claude WITHOUT resolve_model_ids still returns alias', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + runtime: 'claude', + }), + ); + const result = await resolveModel(['gsd-executor'], tmpDir); + expect(result.data).toEqual({ model: 'sonnet', profile: 'balanced' }); + }); + + it('#3643 regression-guard: runtime:claude + resolve_model_ids:"omit" still wins over alias mapping', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + runtime: 'claude', + resolve_model_ids: 'omit', + }), + ); + const result = await resolveModel(['gsd-executor'], tmpDir); + expect(result.data).toEqual({ model: '', profile: 'balanced' }); + }); + + it('#3643 regression-guard: model_overrides[agent] beats claude+resolve_model_ids:true', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + runtime: 'claude', + resolve_model_ids: true, + model_overrides: { 'gsd-executor': 'custom-anthropic-id' }, + }), + ); + const result = await resolveModel(['gsd-executor'], tmpDir); + expect((result.data as Record).model).toBe('custom-anthropic-id'); + }); + it('resolveModel uses workstream config when --ws is specified', async () => { const { resolveModel } = await import('./config-query.js'); // Root config: balanced profile → gsd-executor resolves to 'sonnet' diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index e5b8c1c17..4d26d8271 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -240,5 +240,25 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) = return { data: { model: '', profile } }; } + // #3643: runtime:claude bails out of resolveRuntimeTier (line 149) because + // Claude is the implicit/default runtime, but consumers that asked for + // resolved model IDs still need the full ID (e.g. "claude-sonnet-4-6"), not + // the tier alias. Mirror the CJS branch at get-shit-done/bin/lib/core.cjs + // (`if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;`) + // by consulting the catalog's claude runtime defaults for the resolved tier. + const runtime = typeof (config as Record).runtime === 'string' + ? ((config as Record).runtime as string) + : ''; + // Empty/missing runtime is implicit Claude (per the resolveRuntimeTier bail-out + // at line ~149); without this branch the resolved-IDs path silently fell + // through to the alias return for projects that never set `runtime` explicitly. + const isClaudeRuntime = runtime === '' || runtime === 'claude'; + if (resolveModelIds === true && isClaudeRuntime && isRuntimeTierName(tier)) { + const claudeDefault = resolveRuntimeTierDefault('claude', tier); + if (claudeDefault?.model) { + return { data: { model: claudeDefault.model, profile } }; + } + } + return { data: { model: alias, profile } }; };