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:
parent
804f1d4b94
commit
73558e7557
4 changed files with 187 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue