fix(gsd): preserve custom-model selection on /gsd auto bootstrap (#4122)

When a user picks a custom-provider model via /gsd model (Ollama, vLLM,
LM Studio, OpenAI-compatible proxies — anything defined in
~/.gsd/agent/models.json) and then runs /gsd auto, the bootstrap silently
swaps it out for whichever model PREFERENCES.md happens to list. That
model is invariably a built-in provider (claude-code, anthropic) the user
isn't logged into, so auto-mode immediately fails with
"Not logged in · Please run /login", pauses, and resets the session to
claude-code/claude-sonnet-4-6.

Root cause: #3517 made resolveDefaultSessionModel() (PREFERENCES.md) take
priority over ctx.model (settings.json) in auto-start.ts. That fix was
correct for the scenario where settings.json had a stale built-in default
but PREFERENCES.md was freshly configured, but it has no awareness of
custom providers — PREFERENCES.md cannot reference them, so honoring it
when the session provider is custom always discards the user's explicit
choice.

Add isCustomProvider() to preferences-models.ts which checks whether a
provider is declared in ~/.gsd/agent/models.json (with ~/.pi/agent
fallback). Read the file directly with JSON.parse to avoid pulling in
the model-registry at this call site, and treat any read or parse error
as not-custom so a malformed models.json never breaks bootstrap.

In bootstrapAutoSession(), when the session provider is custom, use
ctx.model directly. Otherwise fall through to the existing #3517
behavior (preferredModel ?? ctx.model).

Tests:
- New behavioral regression in model-isolation.test.ts that mirrors
  the auto-start.ts logic and verifies the four interesting cases:
  custom session beats PREFERENCES.md, built-in session still defers
  to PREFERENCES.md (#3517 preserved), custom session with no
  PREFERENCES.md uses ctx.model, and null ctx.model falls through.
- New string-grep guard in auto-start-model-capture.test.ts that the
  isCustomProvider() call is wired into the snapshot path.
- Updated #3517 grep to allow the new branching shape while still
  asserting preferredModel remains a snapshot source for built-ins.

https://claude.ai/code/session_01QLYCeiXWjSFPEXFxjkSLni
This commit is contained in:
Claude 2026-04-13 17:53:32 +00:00
parent 804f1d4b94
commit 73558e7557
4 changed files with 187 additions and 9 deletions

View file

@ -83,7 +83,7 @@ import { join } from "node:path";
import { sep as pathSep } from "node:path";
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
import { resolveDefaultSessionModel } from "./preferences-models.js";
import { isCustomProvider, resolveDefaultSessionModel } from "./preferences-models.js";
import type { WorktreeResolver } from "./worktree-resolver.js";
export interface BootstrapDeps {
@ -270,11 +270,24 @@ 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.
//
// Exception: when the session model is a custom provider defined in
// ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.),
// the session model wins over PREFERENCES.md. Custom providers can only be
// selected via `/gsd model`, which writes settings.json and therefore
// represents an explicit, recent user choice. PREFERENCES.md cannot
// reference custom providers, so honoring it here would silently start
// auto-mode against a built-in provider the user is not logged into and
// surface as "Not logged in · Please run /login" before pausing auto-mode
// and resetting to claude-code/claude-sonnet-4-6 (#4122).
const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
const startModelSnapshot = preferredModel
?? (ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: null);
const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider);
const startModelSnapshot = sessionProviderIsCustom && ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: (preferredModel
?? (ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: null));
try {
// Validate GSD_PROJECT_ID early so the user gets immediate feedback

View file

@ -7,6 +7,8 @@
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { DynamicRoutingConfig } from "./model-router.js";
import { defaultRoutingConfig } from "./model-router.js";
import type { TokenProfile, InlineLevel } from "./types.js";
@ -185,6 +187,45 @@ export function resolveDefaultSessionModel(
return undefined;
}
/**
* Returns true if `provider` is defined as a custom provider in the user's
* `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
* proxies, etc.).
*
* Used by auto-mode bootstrap to decide whether the session model
* (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers
* are never reachable from `PREFERENCES.md` (which only knows built-in
* providers), so when the user has explicitly selected one, it must take
* priority otherwise auto-mode tries to start the built-in provider from
* PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122).
*
* Reads models.json directly with a lightweight JSON parse to avoid
* pulling in the full model-registry at this call site. Falls back to
* `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
* Any read or parse error yields `false` (treat as not-custom) so a
* malformed models.json never breaks the session bootstrap.
*/
export function isCustomProvider(provider: string | undefined): boolean {
if (!provider) return false;
const candidates = [
join(homedir(), ".gsd", "agent", "models.json"),
join(homedir(), ".pi", "agent", "models.json"),
];
for (const path of candidates) {
if (!existsSync(path)) continue;
try {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as { providers?: Record<string, unknown> };
if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) {
return true;
}
} catch {
// Ignore — malformed models.json must not break bootstrap.
}
}
return false;
}
/**
* Determines the next fallback model to try when the current model fails.
* If the current model is not in the configured list, returns the primary model.

View file

@ -9,7 +9,7 @@ const source = readFileSync(sourcePath, "utf-8");
test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)", () => {
// #3517 changed the snapshot to prefer GSD preferences, but the ordering
// guarantee still holds: the snapshot must be built before guided-flow.
const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
const snapshotIdx = source.indexOf("const startModelSnapshot = ");
assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });');
@ -38,11 +38,47 @@ test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for sta
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");
const snapshotIdx = source.indexOf("const startModelSnapshot = ");
assert.ok(snapshotIdx > -1, "startModelSnapshot should use preferredModel when available");
assert.ok(
preferredIdx < snapshotIdx,
"resolveDefaultSessionModel() must be called before building startModelSnapshot",
);
// preferredModel must still appear as one of the snapshot sources so
// PREFERENCES.md continues to win over a stale settings.json default
// for built-in providers.
const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400);
assert.ok(
snapshotBlock.includes("preferredModel"),
"startModelSnapshot must still consider preferredModel for built-in providers",
);
});
test("bootstrapAutoSession prefers session model over PREFERENCES.md when provider is custom (#4122)", () => {
// Custom providers (Ollama, vLLM, OpenAI-compatible proxies) live in
// ~/.gsd/agent/models.json, not PREFERENCES.md. When the user picks one
// via /gsd model, that selection must win over any preferredModel from
// PREFERENCES.md, otherwise auto-mode tries to start a built-in provider
// the user is not logged into and pauses with "Not logged in".
const customCheckIdx = source.indexOf("isCustomProvider(ctx.model?.provider)");
assert.ok(
customCheckIdx > -1,
"auto-start.ts should call isCustomProvider() to detect custom-model sessions",
);
const snapshotIdx = source.indexOf("const startModelSnapshot = ");
assert.ok(snapshotIdx > -1, "auto-start.ts should build startModelSnapshot");
assert.ok(
customCheckIdx < snapshotIdx,
"isCustomProvider() must be evaluated before building startModelSnapshot",
);
const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400);
assert.ok(
snapshotBlock.includes("sessionProviderIsCustom"),
"startModelSnapshot must branch on sessionProviderIsCustom so custom providers win",
);
});

View file

@ -1,6 +1,7 @@
/**
* Tests for model config isolation between concurrent instances (#650, #1065)
* and GSD preferences override of settings.json defaults (#3517).
* Tests for model config isolation between concurrent instances (#650, #1065),
* GSD preferences override of settings.json defaults (#3517), and custom
* provider precedence over PREFERENCES.md when set via `/gsd model` (#4122).
*/
import { describe, it, beforeEach, afterEach } from "node:test";
@ -229,3 +230,90 @@ describe("GSD preferences override settings.json for session model (#3517)", ()
"settings.json provider must NOT leak through");
});
});
// ─── Custom provider session model wins over PREFERENCES.md (#4122) ─────────
describe("custom provider session model overrides PREFERENCES.md (#4122)", () => {
// Mirrors the auto-start.ts logic:
// sessionProviderIsCustom && ctx.model
// ? ctx.model
// : (preferredModel ?? ctx.model ?? null)
function selectStartModel(args: {
ctxModel: { provider: string; id: string } | null;
preferredModel: { provider: string; id: string } | undefined;
sessionProviderIsCustom: boolean;
}): { provider: string; id: string } | null {
const { ctxModel, preferredModel, sessionProviderIsCustom } = args;
if (sessionProviderIsCustom && ctxModel) {
return { provider: ctxModel.provider, id: ctxModel.id };
}
return preferredModel
?? (ctxModel ? { provider: ctxModel.provider, id: ctxModel.id } : null);
}
it("custom provider from /gsd model wins over PREFERENCES.md built-in default", () => {
// User runs `/gsd model ollama/llama3.1:8b`, then `/gsd auto`.
// PREFERENCES.md still has the project-template claude-code default.
const ctxModel = { provider: "ollama", id: "llama3.1:8b" };
const preferredModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
const snapshot = selectStartModel({
ctxModel,
preferredModel,
sessionProviderIsCustom: true,
});
assert.equal(snapshot?.provider, "ollama",
"custom-provider session model must win over PREFERENCES.md");
assert.equal(snapshot?.id, "llama3.1:8b",
"custom-provider session model id must be preserved");
assert.notEqual(snapshot?.provider, "claude-code",
"claude-code from PREFERENCES.md must NOT be selected when session is custom");
});
it("built-in session provider still defers to PREFERENCES.md (#3517 preserved)", () => {
// ctx.model is a built-in provider (claude-code) but PREFERENCES.md has
// an explicit openai-codex preference. PREFERENCES.md should still win.
const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
const preferredModel = { provider: "openai-codex", id: "gpt-5.4" };
const snapshot = selectStartModel({
ctxModel,
preferredModel,
sessionProviderIsCustom: false,
});
assert.equal(snapshot?.provider, "openai-codex",
"PREFERENCES.md must still win when session provider is built-in");
assert.equal(snapshot?.id, "gpt-5.4");
});
it("custom provider with no PREFERENCES.md still uses ctx.model", () => {
const ctxModel = { provider: "vllm", id: "qwen2.5-coder:32b" };
const snapshot = selectStartModel({
ctxModel,
preferredModel: undefined,
sessionProviderIsCustom: true,
});
assert.equal(snapshot?.provider, "vllm");
assert.equal(snapshot?.id, "qwen2.5-coder:32b");
});
it("null ctx.model with custom flag falls through to preferredModel", () => {
// Defensive: sessionProviderIsCustom can only be true if ctx.model exists,
// but verify the guard works if that invariant is ever broken.
const preferredModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
const snapshot = selectStartModel({
ctxModel: null,
preferredModel,
sessionProviderIsCustom: true,
});
assert.equal(snapshot?.provider, "claude-code",
"should fall back to preferredModel when ctx.model is null");
});
});