fix(gsd): handle bare model IDs in resolveDefaultSessionModel (#3517)

resolveDefaultSessionModel() previously only returned a result for
provider/model format strings, silently ignoring valid bare model IDs
like "gpt-5.4". This meant preferences could fail to override stale
settings.json defaults when users configured models without explicit
provider prefixes.

Now accepts sessionProvider param (ctx.model?.provider) to resolve bare
IDs. Also handles object configs without explicit provider field.
This commit is contained in:
Jeremy 2026-04-04 18:10:50 -05:00
parent fbcd722cf4
commit 70c76d9a1a
4 changed files with 62 additions and 13 deletions

View file

@ -161,7 +161,7 @@ export async function bootstrapAutoSession(
// (#3517). The session model (ctx.model) comes from findInitialModel() which
// reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When
// the user has explicit model preferences in PREFERENCES.md, those should win.
const preferredModel = resolveDefaultSessionModel();
const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
const startModelSnapshot = preferredModel
?? (ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }

View file

@ -116,10 +116,19 @@ export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedMode
* we treat that as the session default. Falls back through execution
* planning first configured model.
*
* Returns `{ provider, id }` parsed from the `provider/model` format,
* or `undefined` if no model preference is configured.
* Accepts an optional `sessionProvider` for bare model IDs that don't
* include an explicit provider prefix (e.g. `gpt-5.4` instead of
* `openai-codex/gpt-5.4`). When a bare ID is found and sessionProvider
* is available, the session provider is used. Without sessionProvider,
* bare IDs are still returned with provider set to the bare ID itself
* so downstream resolution (resolveModelId) can match it.
*
* Returns `{ provider, id }` or `undefined` if no model preference is
* configured.
*/
export function resolveDefaultSessionModel(): { provider: string; id: string } | undefined {
export function resolveDefaultSessionModel(
sessionProvider?: string,
): { provider: string; id: string } | undefined {
const prefs = loadEffectiveGSDPreferences();
if (!prefs?.preferences.models) return undefined;
@ -138,15 +147,38 @@ export function resolveDefaultSessionModel(): { provider: string; id: string } |
for (const cfg of candidates) {
if (!cfg) continue;
const modelStr = typeof cfg === "string"
? cfg
: cfg.provider && !cfg.model.includes("/")
? `${cfg.provider}/${cfg.model}`
: cfg.model;
if (modelStr.includes("/")) {
const slashIdx = modelStr.indexOf("/");
return { provider: modelStr.slice(0, slashIdx), id: modelStr.slice(slashIdx + 1) };
// Normalize to provider + id from the various config shapes
let provider: string | undefined;
let id: string;
if (typeof cfg === "string") {
const slashIdx = cfg.indexOf("/");
if (slashIdx !== -1) {
provider = cfg.slice(0, slashIdx);
id = cfg.slice(slashIdx + 1);
} else {
// Bare model ID (e.g. "gpt-5.4") — use session provider as context
provider = sessionProvider;
id = cfg;
}
} else {
// Object config: { model, provider?, fallbacks? }
if (cfg.provider) {
provider = cfg.provider;
} else if (cfg.model.includes("/")) {
const slashIdx = cfg.model.indexOf("/");
provider = cfg.model.slice(0, slashIdx);
id = cfg.model.slice(slashIdx + 1);
return { provider, id };
} else {
provider = sessionProvider;
}
id = cfg.model;
}
if (provider && id) {
return { provider, id };
}
}

View file

@ -31,9 +31,13 @@ test("bootstrapAutoSession restores autoModeStartModel from the early snapshot (
test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for start model (#3517)", () => {
// resolveDefaultSessionModel() should be called before the snapshot is built
const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel()");
const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel(");
assert.ok(preferredIdx > -1, "auto-start.ts should call resolveDefaultSessionModel()");
// Session provider should be passed for bare model ID resolution
const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
assert.ok(withProviderIdx > -1, "auto-start.ts should pass ctx.model?.provider for bare ID resolution");
const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
assert.ok(snapshotIdx > -1, "startModelSnapshot should use preferredModel when available");

View file

@ -200,6 +200,19 @@ describe("GSD preferences override settings.json for session model (#3517)", ()
"should be null when neither preferences nor ctx.model exist");
});
it("bare model ID uses session provider when available", () => {
// Simulates: PREFERENCES.md has "gpt-5.4" (no provider), session is openai-codex
const preferredModel = { provider: "openai-codex", id: "gpt-5.4" }; // from resolveDefaultSessionModel("openai-codex")
const ctxModel = { provider: "openai-codex", id: "claude-sonnet-4-6" };
const startModelSnapshot = preferredModel
?? { provider: ctxModel.provider, id: ctxModel.id };
assert.equal(startModelSnapshot.provider, "openai-codex");
assert.equal(startModelSnapshot.id, "gpt-5.4",
"bare model ID from preferences should still override ctx.model");
});
it("stale settings.json does not leak when preferences are set", () => {
// Scenario: settings.json has claude-code, PREFERENCES.md has openai-codex
const settingsJsonDefault = { provider: "claude-code", id: "claude-sonnet-4-6" };