fix(#3705): consult model_policy in the install-time bake so agent frontmatter matches dispatch (#3863)

* test(#3705): failing-first coverage for model_policy in the install-time bake

* fix(#3705): consult model_policy in the install-time bake so frontmatter matches dispatch

* fix(#3705): inject the effective runtime into the policy so runtime_tiers is reached

* test(#3705): use assert.doesNotMatch, the assertion that exists

* chore(#3705): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-25 12:11:15 -04:00
committed by GitHub
parent f7df920681
commit e40e9670f8
4 changed files with 276 additions and 3 deletions

View File

@@ -45,8 +45,9 @@ import modelCatalog = require('./model-catalog.cjs');
const { MODEL_PROFILES: GSD_MODEL_PROFILES } = modelCatalog as unknown as { MODEL_PROFILES: Record<string, Record<string, string>> };
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
import modelResolverModule = require('./model-resolver.cjs');
const { resolveTierEntry: gsdResolveTierEntry } = modelResolverModule as {
const { resolveTierEntry: gsdResolveTierEntry, resolveModelPolicy: gsdResolveModelPolicy } = modelResolverModule as {
resolveTierEntry: (opts: { runtime: string; tier: string; overrides: unknown }) => { model?: string } | null;
resolveModelPolicy: (policy: unknown, tier: string | null | undefined) => string | null;
};
interface ReadOptions {
@@ -129,6 +130,14 @@ interface RuntimeProfileMergedConfig {
runtime: string | null;
model_profile: string;
model_profile_overrides: unknown;
/**
* #3705: the provider-neutral policy (#49). Install-time never read it, so a
* project configured for a non-Anthropic provider had its agent frontmatter
* rebaked to catalog `anthropic/claude-*` IDs on every update while
* dispatch-time resolution honoured the policy correctly — two answers from
* one config, and only the frontmatter is what a spawn actually uses.
*/
model_policy: unknown;
}
/**
@@ -169,6 +178,11 @@ function readGsdRuntimeProfileResolver(targetDir: string | null = null): Runtime
(projectConfig && projectConfig.model_profile_overrides) ||
(homeDefaults && homeDefaults.model_profile_overrides) ||
null,
// #3705: same project-wins-over-home precedence as every other key here.
model_policy:
(projectConfig && projectConfig.model_policy) ||
(homeDefaults && homeDefaults.model_policy) ||
null,
};
if (!merged.runtime) return null;
@@ -188,6 +202,33 @@ function readGsdRuntimeProfileResolver(targetDir: string | null = null): Runtime
if (!agentModels) return null;
const tier = agentModels[profile] || agentModels.balanced;
if (!tier) return null;
// #3705: policy sits between the per-agent override and the tier table —
// the position dispatch uses. Measured against `query resolve-model` on the
// same config: policy-only -> the policy model; tier-overrides-only -> the
// override; BOTH -> the policy model. So policy outranks
// `model_profile_overrides`, and an explicit `model_overrides[agent]`
// (applied by resolveAgentModelOverride below) outranks policy.
//
// Calls the exported owner (#49) rather than re-deriving tier -> model here:
// a second implementation of that mapping is the very divergence this issue
// is, one layer down. A null from it (unknown provider, missing tier key, a
// `runtime_tiers` miss) falls through to the tier table — never to null,
// which would omit a frontmatter key that used to be written.
// The policy object must carry the effective runtime before it is resolved.
// `resolveModelPolicy`'s `runtime_tiers` branch reads `policy['runtime']`, but
// the documented config shape (docs/CONFIGURATION.md) puts `runtime` at the
// TOP level and keeps only `provider`/`runtime_tiers` inside `model_policy`.
// Dispatch injects it — `{ ...config.model_policy, runtime: effectiveRuntime }`
// (model-resolver.cts) — and passing the policy unmodified here silently
// skipped `runtime_tiers` entirely, falling through to the flat hi/med/lo keys
// or the catalog tier. That is the same "catalog Anthropic ID baked over a
// configured provider" defect this fix exists to close, so the injection is
// mirrored rather than assumed.
const policyForRuntime = merged.model_policy
? { ...(merged.model_policy as Record<string, unknown>), runtime }
: null;
const policyModel = gsdResolveModelPolicy(policyForRuntime, tier);
if (policyModel) return { model: policyModel };
return gsdResolveTierEntry({
runtime,
tier,