fix(3643): resolve full Claude model id under resolve_model_ids: true (#3648)
* fix(3643): resolve full Claude model id under resolve_model_ids: true
The SDK resolveModel handler bailed out of resolveRuntimeTier for
runtime: "claude" (config-query.ts:149) — the implicit/default runtime —
and fell through to return { model: alias } without consulting the
catalog when resolve_model_ids: true. Consumers received tier aliases
("opus" / "sonnet" / "haiku") instead of the full IDs the CJS resolver
produces (core.cjs:1348-1350 maps via MODEL_ALIAS_MAP).
Added a runtime === 'claude' && resolveModelIds === true branch that
calls resolveRuntimeTierDefault('claude', tier) from the shared
model-catalog so SDK and CJS paths derive Claude IDs from one source
of truth. model_overrides, phase-type tier override, and
resolve_model_ids: "omit" precedence unchanged.
7 new tests in sdk/src/query/config-query.test.ts cover:
- claude + resolve_model_ids:true × {budget, balanced, quality}
× {gsd-executor, gsd-planner} → full Claude IDs
- claude + phase-type override → opus full id
- claude WITHOUT resolve_model_ids → still returns alias (regression)
- claude + resolve_model_ids:"omit" → still wins
- model_overrides[agentType] → still wins
Fixes #3643
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(3643): backfill changeset pr field with #3648
Per DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward — the changeset was
authored with pr: 0 placeholder before the PR existed; now pinned to
the actual PR number.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(3643): resolve full Claude model id when runtime is implicit
CodeRabbit caught that the resolve_model_ids:true branch at
sdk/src/query/config-query.ts only matched explicit runtime === 'claude'.
The resolveRuntimeTier bail-out at line ~149 treats empty/missing
runtime as implicit Claude, so projects without an explicit runtime
field fell through to the alias return — leaving callers that asked
for resolved model IDs with the tier alias instead of the full id
(e.g. 'sonnet' instead of 'claude-sonnet-4-6').
isClaudeRuntime = runtime === '' || runtime === 'claude' covers both
the explicit and implicit cases.
Local: docker gsd-test-summary 11201/0 on plex2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>).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'
|
||||
|
||||
Reference in New Issue
Block a user